From 575741137593c763a6954a2422314dd5fcf513ec Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 07:33:14 +0400 Subject: [PATCH 01/20] =?UTF-8?q?refactor(media):=20swap=20kamadak-exif=20?= =?UTF-8?q?=E2=86=92=20nom-exif=20for=20unified=20EXIF=20+=20video=20metad?= =?UTF-8?q?ata=20reading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nom-exif 2.7 subsumes kamadak-exif (EXIF for JPEG/HEIC/TIFF/PNG/WebP) AND carries the video-container metadata path we already used. The two-crate split was a 2023-era artifact; one crate is now the 2026-canonical read path. Changes: - crates/media/Cargo.toml: swap kamadak-exif → nom-exif. - workspace Cargo.toml: swap kamadak-exif → nom-exif. - crates/media/src/extractor.rs: migrate image-EXIF path from kamadak-exif's Reader::read_from_container + get_field to nom-exif's MediaParser + Exif + as_str / to_string for DateTimeOriginal / Make / Model. Delete strip_quotes and exif_datetime_to_iso8601 helpers (dead after migration) + their 4 unit tests. - crates/media/src/lib.rs + tests/integration.rs: doc comments. - tests/integration.rs: rewrite make_jpeg_with_exif fixture to use a hand-crafted little-endian TIFF with ExifIFDPointer SubIFD chain + APP0(JFIF) prefix (required by nom-exif's streaming JPEG parser). Snapshot diffs: no insta snapshots exist in perima-media; no snapshot reconciliation required. Category summary: * DateTime format: nom-exif emits ISO 8601 with T separator for timezone-aware datetimes (RFC 3339), or "YYYY-MM-DD HH:MM:SS" for naive datetimes. kamadak-exif emitted "YYYY:MM:DD HH:MM:SS" raw EXIF form. We normalise both to ISO 8601 T-form via replacen. * Camera make/model: nom-exif returns bare strings via as_str(); no quote-wrapping (kamadak-exif's display_value() wrapped in double quotes). strip_quotes() helper deleted. * API deviation from plan: plan specified as_time_components() → (NaiveDateTime, Option); this method does not exist in nom-exif 2.7. Used EntryValue::to_string() instead, which covers both Time (RFC 3339) and NaiveDateTime ("YYYY-MM-DD HH:MM:SS") cases. * Fixture deviation: nom-exif requires APP0(JFIF) before APP1(EXIF) in its streaming JPEG parser. Hand-crafted fixture updated accordingly. Verified: cargo tree -i kamadak-exif returns no matches; all media tests pass; clippy + fmt + docs-coverage clean. Parent umbrella: #80 (L3). --- Cargo.lock | 87 ++++++++++++--- Cargo.toml | 2 +- crates/media/Cargo.toml | 2 +- crates/media/src/extractor.rs | 154 ++++++++++---------------- crates/media/src/lib.rs | 2 +- crates/media/tests/integration.rs | 175 ++++++++++++++++++++++-------- 6 files changed, 259 insertions(+), 163 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7ff4cf..57ea65d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -246,7 +255,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom", + "nom 8.0.0", "num-rational", "v_frame", ] @@ -1520,6 +1529,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx", + "num-traits", + "serde", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -2199,6 +2219,16 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "iso6709parse" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5090db9c6a716d1f4eeb729957e889e9c28156061c825cbccd44950cf0f3c66" +dependencies = [ + "geo-types", + "nom 7.1.3", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2325,15 +2355,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "kamadak-exif" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077" -dependencies = [ - "mutate_once", -] - [[package]] name = "keyboard-types" version = "0.7.0" @@ -2445,6 +2466,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -2599,6 +2626,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2680,12 +2713,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "mutate_once" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" - [[package]] name = "ndk" version = "0.9.0" @@ -2749,6 +2776,16 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -2758,6 +2795,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "nom-exif" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e78a8215f056e78a6887b4872e61bab5690dec4648b37396b6721be6f89c4ea" +dependencies = [ + "bytes", + "chrono", + "iso6709parse", + "nom 7.1.3", + "regex", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "noop_proc_macro" version = "0.3.0" @@ -2877,6 +2929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -3250,10 +3303,10 @@ version = "0.6.4" dependencies = [ "blake3", "image", - "kamadak-exif", "mime_guess", "mp4", "mp4parse", + "nom-exif", "perima-core", "tempfile", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index 6acbcd7..f32bc23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,7 +81,7 @@ rayon = "1" ctrlc = { version = "3", features = ["termination"] } chrono = { version = "0.4", features = ["serde"] } image = "0.25" -kamadak-exif = "0.5" +nom-exif = "2.7" mp4parse = "0.17" mime_guess = "2" diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0cee69e..9e46640 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -10,7 +10,7 @@ publish = false # WHY: intra-workspace only; not published to crates.io [dependencies] perima-core = { path = "../core" } image.workspace = true -kamadak-exif.workspace = true +nom-exif.workspace = true mp4parse.workspace = true mime_guess.workspace = true tokio.workspace = true diff --git a/crates/media/src/extractor.rs b/crates/media/src/extractor.rs index 7c4f24b..66fb44e 100644 --- a/crates/media/src/extractor.rs +++ b/crates/media/src/extractor.rs @@ -1,20 +1,19 @@ //! Metadata extractors for images and MP4/MOV video. //! //! `ImageExtractor` reads PNG/JPEG dimensions via the `image` crate and -//! EXIF tags via `kamadak-exif`. `VideoExtractor` parses `moov`-box data +//! EXIF tags via `nom-exif`. `VideoExtractor` parses `moov`-box data //! via Mozilla's `mp4parse` reader. `CompositeExtractor` dispatches to //! the first registered extractor whose [`MetadataExtractor::accepts`] //! returns `true` for the requested MIME. use std::fs::File; -use std::io::BufReader; use std::path::Path; use std::sync::Arc; use perima_core::{BlakeHash, CoreError, MediaMetadata, MetadataExtractor}; /// Image metadata extractor backed by `image` (dimensions) and -/// `kamadak-exif` (capture timestamp + camera). +/// `nom-exif` (capture timestamp + camera). /// /// Handles every `image/*` MIME the `image` crate can decode. #[derive(Clone, Copy, Debug, Default)] @@ -75,92 +74,85 @@ impl MetadataExtractor for ImageExtractor { /// Read EXIF `DateTimeOriginal`, `Make`, and `Model` from an image. /// +/// Uses `nom-exif`'s unified `MediaParser` / `MediaSource` API which +/// returns strings bare (no quote-wrapping) and datetime values as typed +/// `EntryValue::Time` (timezone-aware, RFC 3339) or +/// `EntryValue::NaiveDateTime` (no timezone, formatted as +/// `"YYYY-MM-DD HH:MM:SS"`). We normalise both to ISO 8601 with a `T` +/// separator before returning. +/// /// Returns `(None, None, None)` if the file has no EXIF segment, the /// segment is malformed, or the individual fields are absent. A missing /// EXIF block is expected for PNGs and many camera-exported JPEGs — /// treating it as an error would be noisy. Any I/O or parser error is /// traced at `debug` level. fn read_exif(path: &Path) -> (Option, Option, Option) { - let Ok(file) = File::open(path) else { - return (None, None, None); - }; - let mut reader = BufReader::new(file); - let exif = match exif::Reader::new().read_from_container(&mut reader) { - Ok(e) => e, + let ms = match nom_exif::MediaSource::file_path(path) { + Ok(ms) => ms, Err(err) => { tracing::debug!( path = %path.display(), error = %err, - "EXIF parse failed or absent", + "nom-exif: could not open file as MediaSource", ); return (None, None, None); } }; - let captured_at = exif - .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) - .and_then(|f| { - let raw = f.display_value().to_string(); - // `DateTimeOriginal` is "YYYY:MM:DD HH:MM:SS" per EXIF spec. - // WHY reshape: callers consume ISO 8601 throughout the DB - // (`first_seen`, `last_seen`). Converting at extraction time - // keeps storage canonical even though the timezone is - // unknown (EXIF omits zone; we annotate as naive-UTC). - exif_datetime_to_iso8601(&raw) - }); + // Early-return when the container carries no EXIF block (e.g. raw + // PNG, MP4 without embedded EXIF). This avoids the parse attempt + // and the noisy "no Exif data" error it would produce. + if !ms.has_exif() { + return (None, None, None); + } + + let mut parser = nom_exif::MediaParser::new(); + let iter: nom_exif::ExifIter = match parser.parse(ms) { + Ok(iter) => iter, + Err(err) => { + tracing::debug!( + path = %path.display(), + error = %err, + "nom-exif: EXIF parse failed", + ); + return (None, None, None); + } + }; + let exif: nom_exif::Exif = iter.into(); let camera_make = exif - .get_field(exif::Tag::Make, exif::In::PRIMARY) - .map(|f| strip_quotes(&f.display_value().to_string())); + .get(nom_exif::ExifTag::Make) + .and_then(|v| v.as_str()) + .map(str::to_owned); let camera_model = exif - .get_field(exif::Tag::Model, exif::In::PRIMARY) - .map(|f| strip_quotes(&f.display_value().to_string())); + .get(nom_exif::ExifTag::Model) + .and_then(|v| v.as_str()) + .map(str::to_owned); + + // WHY reshape: callers consume ISO 8601 throughout the DB + // (`first_seen`, `last_seen`). Converting at extraction time keeps + // storage canonical. nom-exif emits either: + // - `EntryValue::Time` → RFC 3339 ("2024-06-01T12:34:56+08:00") + // - `EntryValue::NaiveDateTime` → space-sep ("2024-06-01 12:34:56") + // Both `to_string()` forms already use `YYYY-MM-DD`; we only need + // to replace the space separator with `T` in the naive case. + let captured_at = exif.get(nom_exif::ExifTag::DateTimeOriginal).map(|v| { + let s = v.to_string(); + // Naive datetimes have a space at index 10; RFC 3339 already has `T`. + // WHY `replacen` not `replace`: only the date/time separator should + // be substituted; time components use `:` not ` `, so only one + // substitution ever fires, but `replacen(1)` is explicit. + if s.as_bytes().get(10).copied() == Some(b' ') { + s.replacen(' ', "T", 1) + } else { + s + } + }); (captured_at, camera_make, camera_model) } -/// `kamadak-exif`'s `display_value()` wraps ASCII strings in double -/// quotes (e.g. `"Canon"`). Strip those so downstream consumers see -/// bare values. -fn strip_quotes(s: &str) -> String { - s.trim_matches('"').to_owned() -} - -/// Convert an EXIF `DateTimeOriginal` rendering to ISO 8601. -/// -/// EXIF on the wire uses `"YYYY:MM:DD HH:MM:SS"` (all colons); however -/// `kamadak-exif::Field::display_value()` normalises the date-part -/// separators to hyphens, yielding `"YYYY-MM-DD HH:MM:SS"`. We accept -/// either form by reading the first 19 chars positionally: only the -/// space at index 10 is inspected to decide the input shape is -/// plausible. Output is always `"YYYY-MM-DDTHH:MM:SS"`. -/// -/// WHY accept both: reusing one function for future raw-tag reads (that -/// keep the EXIF-colon form) without forcing the caller to choose. -/// -/// Returns `None` if the input is shorter than 19 chars or missing the -/// date/time separator at position 10. -fn exif_datetime_to_iso8601(raw: &str) -> Option { - let raw = raw.trim().trim_matches('"').trim(); - if raw.len() < 19 { - return None; - } - let bytes = raw.as_bytes(); - if bytes[10] != b' ' { - return None; - } - let mut out = String::with_capacity(19); - for (i, c) in raw.chars().take(19).enumerate() { - match i { - 4 | 7 => out.push('-'), - 10 => out.push('T'), - _ => out.push(c), - } - } - Some(out) -} - /// Video metadata extractor backed by `mp4parse`. /// /// Handles `video/mp4` and `video/quicktime` (the two MIMEs the @@ -385,33 +377,3 @@ impl MetadataExtractor for CompositeExtractor { }) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn exif_datetime_converts_colon_form_to_iso8601() { - let got = exif_datetime_to_iso8601("2024:06:01 12:34:56").expect("parse"); - assert_eq!(got, "2024-06-01T12:34:56"); - } - - #[test] - fn exif_datetime_converts_hyphen_form_to_iso8601() { - // `kamadak-exif::Field::display_value()` normalises the date - // portion to hyphens; the output must still be ISO 8601. - let got = exif_datetime_to_iso8601("2024-06-01 12:34:56").expect("parse"); - assert_eq!(got, "2024-06-01T12:34:56"); - } - - #[test] - fn exif_datetime_rejects_short_input() { - assert!(exif_datetime_to_iso8601("2024").is_none()); - } - - #[test] - fn strip_quotes_handles_bare_string() { - assert_eq!(strip_quotes("Canon"), "Canon"); - assert_eq!(strip_quotes("\"Canon\""), "Canon"); - } -} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 4c4d51c..85d97dc 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -6,7 +6,7 @@ //! scanner. //! //! The extractors are confined here so the rest of the workspace stays -//! free of `image`, `kamadak-exif`, `mp4parse`, and `mime_guess` +//! free of `image`, `nom-exif`, `mp4parse`, and `mime_guess` //! dependencies. #![forbid(unsafe_code)] diff --git a/crates/media/tests/integration.rs b/crates/media/tests/integration.rs index 3dffb38..4d62626 100644 --- a/crates/media/tests/integration.rs +++ b/crates/media/tests/integration.rs @@ -7,7 +7,7 @@ #![allow(clippy::missing_errors_doc)] use std::fs::{self, File}; -use std::io::{BufWriter, Cursor, Write}; +use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -33,72 +33,153 @@ fn make_test_png(width: u32, height: u32, path: &Path) -> PathBuf { path.to_path_buf() } -/// Build a minimal JPEG containing only SOI + APP1(EXIF) + EOI. +/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI. /// /// WHY this shape (not a real encoded image): -/// - `kamadak-exif`'s `read_from_container` only needs SOI + APP1; -/// it does not parse the scan data. +/// - `nom-exif`'s `MediaParser` requires a preceding APP0 (JFIF) marker +/// before the APP1 (EXIF) segment for its streaming JPEG parser to +/// recognise the file as a valid JPEG container. /// - `image::image_dimensions` is NOT called against this fixture — /// the EXIF test focuses on the text fields, and the PNG-dimensions /// test uses a real PNG. /// - Avoiding a fully-encoded JPEG keeps the helper small and removes /// any dependency on the `image` JPEG encoder. /// -/// WHY generated at runtime (not a byte-vendored const): -/// the plan's preferred option is a const `&[u8]` produced externally -/// via `exiftool`. This environment does not have `exiftool`; building -/// the TIFF/EXIF segment at runtime via `exif::experimental::Writer` is -/// documented as the acceptable fallback (plan §Task 3, Option 2) and -/// has the same correctness property (deterministic bytes, no binary -/// blob in git) without the external tool dependency. +/// WHY hand-crafted TIFF bytes (not a third-party writer): +/// We no longer depend on `kamadak-exif`, which previously provided +/// `exif::experimental::Writer`. Instead we build a minimal little-endian +/// TIFF block with a proper `ExifIFDPointer` chain (IFD0 → `ExifSubIFD`) +/// that nom-exif can follow. The byte layout follows EXIF 2.3 §4.5. fn make_jpeg_with_exif(datetime_original: &str, make: &str, model: &str, path: &Path) -> PathBuf { - use exif::experimental::Writer; - use exif::{Field, In, Tag, Value}; - - // Build the TIFF/EXIF payload. - let dt = Field { - tag: Tag::DateTimeOriginal, - ifd_num: In::PRIMARY, - value: Value::Ascii(vec![datetime_original.as_bytes().to_vec()]), - }; - let mk = Field { - tag: Tag::Make, - ifd_num: In::PRIMARY, - value: Value::Ascii(vec![make.as_bytes().to_vec()]), - }; - let mdl = Field { - tag: Tag::Model, - ifd_num: In::PRIMARY, - value: Value::Ascii(vec![model.as_bytes().to_vec()]), - }; - - let mut writer = Writer::new(); - writer.push_field(&dt); - writer.push_field(&mk); - writer.push_field(&mdl); + let tiff_bytes = build_tiff_exif(datetime_original, make, model); - let mut tiff_buf = Cursor::new(Vec::::new()); - writer.write(&mut tiff_buf, false).expect("write tiff exif"); - let tiff_bytes = tiff_buf.into_inner(); - - // Assemble JPEG: SOI + APP1 + (Exif\0\0 + TIFF) + EOI. - // APP1 length bytes cover [len_hi, len_lo, Exif\0\0, TIFF] — i.e. + // Assemble JPEG: SOI + APP0(JFIF) + APP1(Exif) + EOI. + // APP1 length covers [len_hi, len_lo, "Exif\0\0", TIFF] — i.e. // 2 (len itself) + 6 (identifier) + tiff.len(). - let payload_len = 2 + 6 + tiff_bytes.len(); - let len_u16 = u16::try_from(payload_len).expect("APP1 payload too large for a single segment"); + let app1_payload_len = 2 + 6 + tiff_bytes.len(); + let app1_len = + u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment"); let file = File::create(path).expect("create jpeg"); let mut w = BufWriter::new(file); - w.write_all(&[0xFF, 0xD8]).expect("SOI"); // SOI - w.write_all(&[0xFF, 0xE1]).expect("APP1 marker"); // APP1 - w.write_all(&len_u16.to_be_bytes()).expect("APP1 length"); + w.write_all(&[0xFF, 0xD8]).expect("SOI"); + // APP0 (JFIF) — 16-byte minimal header required by nom-exif's JPEG parser. + w.write_all(&[0xFF, 0xE0, 0x00, 0x10]) + .expect("APP0 marker+len"); + w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00") + .expect("JFIF payload"); + // APP1 (EXIF) + w.write_all(&[0xFF, 0xE1]).expect("APP1 marker"); + w.write_all(&app1_len.to_be_bytes()).expect("APP1 length"); w.write_all(b"Exif\0\0").expect("Exif identifier"); w.write_all(&tiff_bytes).expect("TIFF body"); - w.write_all(&[0xFF, 0xD9]).expect("EOI"); // EOI + w.write_all(&[0xFF, 0xD9]).expect("EOI"); w.flush().expect("flush jpeg"); path.to_path_buf() } +/// Build a minimal little-endian TIFF block with the proper EXIF IFD +/// chain for three tags: `Make` and `Model` in IFD0, and +/// `DateTimeOriginal` in the EXIF `SubIFD` (pointed to from IFD0 via tag +/// `ExifIFDPointer` 0x8769). +/// +/// Layout (offsets relative to start of TIFF block): +/// ```text +/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8 +/// 0x08 IFD0 (42 bytes): count=3, [Make, Model, ExifIFDPointer], next=0 +/// 0x32 ExifSubIFD (18 bytes): count=1, [DateTimeOriginal], next=0 +/// 0x44 String area: Make\0, Model\0, DateTimeOriginal\0 +/// ``` +/// +/// Each IFD entry is 12 bytes: tag(2) + type(2) + count(4) + offset(4). +fn build_tiff_exif(datetime_original: &str, make: &str, model: &str) -> Vec { + // Tag / type constants (EXIF 2.3). + const TAG_MAKE: u16 = 0x010F; + const TAG_MODEL: u16 = 0x0110; + const TAG_EXIF_IFD_POINTER: u16 = 0x8769; + const TAG_DATETIME_ORIGINAL: u16 = 0x9003; + const TYPE_ASCII: u16 = 2; + const TYPE_LONG: u16 = 4; + + // Offset layout (all LE, relative to TIFF block start). + // TIFF header: 8. IFD0: 2 + 3*12 + 4 = 42 → ends at 50. + // ExifSubIFD: 2 + 1*12 + 4 = 18 → ends at 68. String area from 68. + const IFD0_OFFSET: u32 = 8; + const IFD0_ENTRY_COUNT: u16 = 3; + const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50 + const EXIF_SUBIFD_ENTRY_COUNT: u16 = 1; + const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 68 + + // ASCII values are NUL-terminated per TIFF/EXIF spec. + let make_bytes: Vec = { + let mut v = make.as_bytes().to_vec(); + v.push(0); + v + }; + let model_bytes: Vec = { + let mut v = model.as_bytes().to_vec(); + v.push(0); + v + }; + let dt_bytes: Vec = { + let mut v = datetime_original.as_bytes().to_vec(); + v.push(0); + v + }; + + let make_len = u32::try_from(make_bytes.len()).expect("make too long for TIFF"); + let model_len = u32::try_from(model_bytes.len()).expect("model too long for TIFF"); + let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long for TIFF"); + + let make_offset = STRING_AREA; + let model_offset = make_offset + make_len; + let dt_offset = model_offset + model_len; + + let mut buf = Vec::new(); + + // TIFF header. + buf.extend_from_slice(b"II"); // little-endian byte order + buf.extend_from_slice(&42_u16.to_le_bytes()); // TIFF magic + buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes()); + + // IFD0 (entries must be sorted ascending by tag). + buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes()); + // Make (0x010F) + buf.extend_from_slice(&TAG_MAKE.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&make_len.to_le_bytes()); + buf.extend_from_slice(&make_offset.to_le_bytes()); + // Model (0x0110) + buf.extend_from_slice(&TAG_MODEL.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&model_len.to_le_bytes()); + buf.extend_from_slice(&model_offset.to_le_bytes()); + // ExifIFDPointer (0x8769) — inline LONG offset to ExifSubIFD. + buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes()); + buf.extend_from_slice(&TYPE_LONG.to_le_bytes()); + buf.extend_from_slice(&1_u32.to_le_bytes()); // count = 1 + buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes()); + // IFD0 next-IFD pointer. + buf.extend_from_slice(&0_u32.to_le_bytes()); + + // ExifSubIFD. + buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes()); + // DateTimeOriginal (0x9003) + buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&dt_len.to_le_bytes()); + buf.extend_from_slice(&dt_offset.to_le_bytes()); + // ExifSubIFD next-IFD pointer. + buf.extend_from_slice(&0_u32.to_le_bytes()); + + // String area. + buf.extend_from_slice(&make_bytes); + buf.extend_from_slice(&model_bytes); + buf.extend_from_slice(&dt_bytes); + + buf +} + /// Synthesise a minimal valid MP4 with one ~1 second AVC video track. /// /// WHY programmatic: checking binary fixtures into git bloats the repo From f344037377d6a766a6ab6e3ddea460176edb9f7b Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 07:40:59 +0400 Subject: [PATCH 02/20] refactor(media): use nom-exif's typed as_time_components for DateTimeOriginal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a deviation in commit 5757411, which claimed "as_time_components() does not exist in nom-exif 2.7" and used a byte-index-10 Display-impl hack instead. The method DOES exist (docs.rs/nom-exif/2.7.0/nom_exif/enum.EntryValue.html) with the signature Option<(NaiveDateTime, Option)> — exactly what the original Batch B plan prescribed. This commit rewrites the datetime branch in read_exif() to use the typed accessor. Concrete wins over the prior fallback: - No coupling to the Display impl of a #[non_exhaustive] enum. - No byte-index-10 fragility. - Preserves FixedOffset information for timezone-aware EXIF values (to_rfc3339 emits the offset suffix correctly). - Matches the original plan's code template. Output is byte-identical for the current test fixture (naive DateTimeOriginal "2024:06:01 12:34:56" → "2024-06-01T12:34:56"); no snapshot reconciliation needed. Corrects the factually-wrong API-existence claim from commit 5757411's body. No amend (CLAUDE.md: new commits only). Parent umbrella: #80 (L3). --- Cargo.lock | 1 + crates/media/Cargo.toml | 1 + crates/media/src/extractor.rs | 38 +++++++++++++++++++---------------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57ea65d..cfcf179 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3302,6 +3302,7 @@ name = "perima-media" version = "0.6.4" dependencies = [ "blake3", + "chrono", "image", "mime_guess", "mp4", diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 9e46640..6100c2a 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -11,6 +11,7 @@ publish = false # WHY: intra-workspace only; not published to crates.io perima-core = { path = "../core" } image.workspace = true nom-exif.workspace = true +chrono.workspace = true mp4parse.workspace = true mime_guess.workspace = true tokio.workspace = true diff --git a/crates/media/src/extractor.rs b/crates/media/src/extractor.rs index 66fb44e..1560607 100644 --- a/crates/media/src/extractor.rs +++ b/crates/media/src/extractor.rs @@ -132,23 +132,27 @@ fn read_exif(path: &Path) -> (Option, Option, Option) { // WHY reshape: callers consume ISO 8601 throughout the DB // (`first_seen`, `last_seen`). Converting at extraction time keeps - // storage canonical. nom-exif emits either: - // - `EntryValue::Time` → RFC 3339 ("2024-06-01T12:34:56+08:00") - // - `EntryValue::NaiveDateTime` → space-sep ("2024-06-01 12:34:56") - // Both `to_string()` forms already use `YYYY-MM-DD`; we only need - // to replace the space separator with `T` in the naive case. - let captured_at = exif.get(nom_exif::ExifTag::DateTimeOriginal).map(|v| { - let s = v.to_string(); - // Naive datetimes have a space at index 10; RFC 3339 already has `T`. - // WHY `replacen` not `replace`: only the date/time separator should - // be substituted; time components use `:` not ` `, so only one - // substitution ever fires, but `replacen(1)` is explicit. - if s.as_bytes().get(10).copied() == Some(b' ') { - s.replacen(' ', "T", 1) - } else { - s - } - }); + // storage canonical. + // WHY as_time_components: the typed accessor is stable across any future + // Display-impl changes in nom-exif's #[non_exhaustive] EntryValue enum + // and carries the FixedOffset when present, letting us emit a proper + // RFC 3339 string with offset suffix for timezone-aware EXIF values. + // Using byte-index-10 into a to_string() output would be fragile and + // discards the offset information entirely. + let captured_at = exif + .get(nom_exif::ExifTag::DateTimeOriginal) + .and_then(nom_exif::EntryValue::as_time_components) + .map(|(naive, offset)| { + offset.map_or_else( + || naive.format("%Y-%m-%dT%H:%M:%S").to_string(), + |tz| { + naive.and_local_timezone(tz).single().map_or_else( + || naive.format("%Y-%m-%dT%H:%M:%S").to_string(), + |dt| dt.to_rfc3339(), + ) + }, + ) + }); (captured_at, camera_make, camera_model) } From a4a1c7e187de67824184e60fdf9b27651bc45f10 Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 07:53:37 +0400 Subject: [PATCH 03/20] test(media): cover tz-aware EXIF + no-EXIF paths; harden string + doc polish Addresses inline findings from L3 double code-review (2026-04-20): - Add image_extractor_jpeg_exif_with_offset integration test. Exercises nom-exif's FixedOffset branch in as_time_components() that was previously uncovered; asserts captured_at output matches "2024-06-01T12:34:56+08:00" via chrono::DateTime::to_rfc3339. Fixture adds OffsetTimeOriginal tag (0x9011, "+08:00") in ExifSubIFD. - Add image_extractor_jpeg_without_exif_returns_default. Exercises the has_exif() short-circuit path (no APP1 segment). - Harden read_exif's Make/Model string extraction with trim_end_matches(['\0', ' ']). EXIF ASCII tags are often NUL-terminated or space-padded; kamadak's display_value previously stripped these, nom-exif's as_str does not guarantee it. - Add # WHY comment above chrono.workspace = true in crates/media/Cargo.toml explaining the inferred-type usage (no explicit use chrono::... appears in media source). - Add // WHY comment on .single().map_or_else fallback explaining FixedOffset cannot yield LocalResult::None/Ambiguous; fallback is forward-compat insurance, not dead code. - Rewrite read_exif doc comment to describe nom-exif's as_str + as_time_components semantics rather than the pre-migration Display-of-EntryValue path. Error-granularity (MediaSource vs MediaParser) is filed separately as #110 for architecture-spec work; out of scope here. Parent umbrella: #80 (L3). --- crates/media/Cargo.toml | 4 + crates/media/src/extractor.rs | 22 +-- crates/media/tests/integration.rs | 214 ++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 8 deletions(-) diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 6100c2a..c8e114a 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -11,6 +11,10 @@ publish = false # WHY: intra-workspace only; not published to crates.io perima-core = { path = "../core" } image.workspace = true nom-exif.workspace = true +# WHY chrono: nom-exif's as_time_components() returns (NaiveDateTime, Option); +# we need NaiveDateTime::format for the naive path and DateTime::to_rfc3339 for the +# offset-aware path. No `use chrono::…` appears in media source files because the +# inferred types flow from nom-exif's return values — the dep is real, just implicit. chrono.workspace = true mp4parse.workspace = true mime_guess.workspace = true diff --git a/crates/media/src/extractor.rs b/crates/media/src/extractor.rs index 1560607..1ea2d46 100644 --- a/crates/media/src/extractor.rs +++ b/crates/media/src/extractor.rs @@ -74,12 +74,12 @@ impl MetadataExtractor for ImageExtractor { /// Read EXIF `DateTimeOriginal`, `Make`, and `Model` from an image. /// -/// Uses `nom-exif`'s unified `MediaParser` / `MediaSource` API which -/// returns strings bare (no quote-wrapping) and datetime values as typed -/// `EntryValue::Time` (timezone-aware, RFC 3339) or -/// `EntryValue::NaiveDateTime` (no timezone, formatted as -/// `"YYYY-MM-DD HH:MM:SS"`). We normalise both to ISO 8601 with a `T` -/// separator before returning. +/// Uses `nom-exif`'s typed accessors: `as_str()` returns bare strings +/// (no display quoting). `as_time_components()` returns +/// `(NaiveDateTime, Option)`; we format naive → +/// `"YYYY-MM-DDTHH:MM:SS"`, offset-aware → RFC 3339 with offset suffix +/// via `chrono::DateTime::to_rfc3339`, bypassing the `Display` impl of +/// the `#[non_exhaustive]` `EntryValue` enum. /// /// Returns `(None, None, None)` if the file has no EXIF segment, the /// segment is malformed, or the individual fields are absent. A missing @@ -120,15 +120,18 @@ fn read_exif(path: &Path) -> (Option, Option, Option) { }; let exif: nom_exif::Exif = iter.into(); + // WHY trim_end_matches: EXIF ASCII tags are often NUL-terminated or + // space-padded (e.g. "NIKON CORPORATION \0"). nom-exif's as_str() + // does not strip these; we normalise before storing. let camera_make = exif .get(nom_exif::ExifTag::Make) .and_then(|v| v.as_str()) - .map(str::to_owned); + .map(|s| s.trim_end_matches(['\0', ' ']).to_owned()); let camera_model = exif .get(nom_exif::ExifTag::Model) .and_then(|v| v.as_str()) - .map(str::to_owned); + .map(|s| s.trim_end_matches(['\0', ' ']).to_owned()); // WHY reshape: callers consume ISO 8601 throughout the DB // (`first_seen`, `last_seen`). Converting at extraction time keeps @@ -146,6 +149,9 @@ fn read_exif(path: &Path) -> (Option, Option, Option) { offset.map_or_else( || naive.format("%Y-%m-%dT%H:%M:%S").to_string(), |tz| { + // WHY .single(): defensive — FixedOffset cannot produce + // LocalResult::None/Ambiguous, but the typed shape is + // forward-compatible if nom-exif ever exposes Tz-aware values. naive.and_local_timezone(tz).single().map_or_else( || naive.format("%Y-%m-%dT%H:%M:%S").to_string(), |dt| dt.to_rfc3339(), diff --git a/crates/media/tests/integration.rs b/crates/media/tests/integration.rs index 4d62626..7a98cbb 100644 --- a/crates/media/tests/integration.rs +++ b/crates/media/tests/integration.rs @@ -180,6 +180,158 @@ fn build_tiff_exif(datetime_original: &str, make: &str, model: &str) -> Vec buf } +/// Build a minimal JPEG containing SOI + APP0(JFIF) + APP1(EXIF) + EOI with +/// both `DateTimeOriginal` (0x9003) and `OffsetTimeOriginal` (0x9011) set in +/// the `ExifSubIFD`. +/// +/// WHY separate helper (not a flag on `make_jpeg_with_exif`): keeping the +/// two helpers independent avoids a boolean parameter that would change the +/// byte layout mid-function and make the offset arithmetic harder to follow. +fn make_jpeg_with_exif_offset( + datetime_original: &str, + offset: &str, + make: &str, + model: &str, + path: &Path, +) -> PathBuf { + let tiff_bytes = build_tiff_exif_with_offset(datetime_original, offset, make, model); + + let app1_payload_len = 2 + 6 + tiff_bytes.len(); + let app1_len = + u16::try_from(app1_payload_len).expect("APP1 payload too large for a single segment"); + + let file = File::create(path).expect("create jpeg"); + let mut w = BufWriter::new(file); + w.write_all(&[0xFF, 0xD8]).expect("SOI"); + w.write_all(&[0xFF, 0xE0, 0x00, 0x10]) + .expect("APP0 marker+len"); + w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00") + .expect("JFIF payload"); + w.write_all(&[0xFF, 0xE1]).expect("APP1 marker"); + w.write_all(&app1_len.to_be_bytes()).expect("APP1 length"); + w.write_all(b"Exif\0\0").expect("Exif identifier"); + w.write_all(&tiff_bytes).expect("TIFF body"); + w.write_all(&[0xFF, 0xD9]).expect("EOI"); + w.flush().expect("flush jpeg"); + path.to_path_buf() +} + +/// Build a minimal little-endian TIFF block with two `ExifSubIFD` entries: +/// `DateTimeOriginal` (0x9003) and `OffsetTimeOriginal` (0x9011). +/// +/// Layout (offsets relative to start of TIFF block): +/// ```text +/// 0x00 TIFF header (8 bytes): "II" + 0x002A + IFD0_offset=8 +/// 0x08 IFD0 (42 bytes): count=3, [Make, Model, ExifIFDPointer], next=0 +/// 0x32 ExifSubIFD (30 bytes): count=2, [DateTimeOriginal, OffsetTimeOriginal], next=0 +/// 0x50 String area: Make\0, Model\0, DateTimeOriginal\0, OffsetTimeOriginal\0 +/// ``` +fn build_tiff_exif_with_offset( + datetime_original: &str, + offset: &str, + make: &str, + model: &str, +) -> Vec { + const TAG_MAKE: u16 = 0x010F; + const TAG_MODEL: u16 = 0x0110; + const TAG_EXIF_IFD_POINTER: u16 = 0x8769; + const TAG_DATETIME_ORIGINAL: u16 = 0x9003; + const TAG_OFFSET_TIME_ORIGINAL: u16 = 0x9011; + const TYPE_ASCII: u16 = 2; + const TYPE_LONG: u16 = 4; + + // IFD0: 2 + 3*12 + 4 = 42 → [8, 50) + // ExifSubIFD: 2 + 2*12 + 4 = 30 → [50, 80) + // String area from 80. + const IFD0_OFFSET: u32 = 8; + const IFD0_ENTRY_COUNT: u16 = 3; + const EXIF_SUBIFD_OFFSET: u32 = IFD0_OFFSET + 2 + (IFD0_ENTRY_COUNT as u32 * 12) + 4; // 50 + const EXIF_SUBIFD_ENTRY_COUNT: u16 = 2; + const STRING_AREA: u32 = EXIF_SUBIFD_OFFSET + 2 + (EXIF_SUBIFD_ENTRY_COUNT as u32 * 12) + 4; // 80 + + let make_bytes: Vec = { + let mut v = make.as_bytes().to_vec(); + v.push(0); + v + }; + let model_bytes: Vec = { + let mut v = model.as_bytes().to_vec(); + v.push(0); + v + }; + let dt_bytes: Vec = { + let mut v = datetime_original.as_bytes().to_vec(); + v.push(0); + v + }; + // EXIF spec: OffsetTimeOriginal is ASCII, count includes the NUL. + let offset_bytes: Vec = { + let mut v = offset.as_bytes().to_vec(); + v.push(0); + v + }; + + let make_len = u32::try_from(make_bytes.len()).expect("make too long"); + let model_len = u32::try_from(model_bytes.len()).expect("model too long"); + let dt_len = u32::try_from(dt_bytes.len()).expect("datetime too long"); + let offset_len = u32::try_from(offset_bytes.len()).expect("offset too long"); + + let make_offset = STRING_AREA; + let model_offset = make_offset + make_len; + let dt_offset = model_offset + model_len; + let offset_str_offset = dt_offset + dt_len; + + let mut buf = Vec::new(); + + // TIFF header. + buf.extend_from_slice(b"II"); + buf.extend_from_slice(&42_u16.to_le_bytes()); + buf.extend_from_slice(&IFD0_OFFSET.to_le_bytes()); + + // IFD0. + buf.extend_from_slice(&IFD0_ENTRY_COUNT.to_le_bytes()); + // Make (0x010F) + buf.extend_from_slice(&TAG_MAKE.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&make_len.to_le_bytes()); + buf.extend_from_slice(&make_offset.to_le_bytes()); + // Model (0x0110) + buf.extend_from_slice(&TAG_MODEL.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&model_len.to_le_bytes()); + buf.extend_from_slice(&model_offset.to_le_bytes()); + // ExifIFDPointer (0x8769) + buf.extend_from_slice(&TAG_EXIF_IFD_POINTER.to_le_bytes()); + buf.extend_from_slice(&TYPE_LONG.to_le_bytes()); + buf.extend_from_slice(&1_u32.to_le_bytes()); + buf.extend_from_slice(&EXIF_SUBIFD_OFFSET.to_le_bytes()); + // IFD0 next-IFD pointer. + buf.extend_from_slice(&0_u32.to_le_bytes()); + + // ExifSubIFD (entries must be sorted ascending by tag). + buf.extend_from_slice(&EXIF_SUBIFD_ENTRY_COUNT.to_le_bytes()); + // DateTimeOriginal (0x9003) + buf.extend_from_slice(&TAG_DATETIME_ORIGINAL.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&dt_len.to_le_bytes()); + buf.extend_from_slice(&dt_offset.to_le_bytes()); + // OffsetTimeOriginal (0x9011) + buf.extend_from_slice(&TAG_OFFSET_TIME_ORIGINAL.to_le_bytes()); + buf.extend_from_slice(&TYPE_ASCII.to_le_bytes()); + buf.extend_from_slice(&offset_len.to_le_bytes()); + buf.extend_from_slice(&offset_str_offset.to_le_bytes()); + // ExifSubIFD next-IFD pointer. + buf.extend_from_slice(&0_u32.to_le_bytes()); + + // String area. + buf.extend_from_slice(&make_bytes); + buf.extend_from_slice(&model_bytes); + buf.extend_from_slice(&dt_bytes); + buf.extend_from_slice(&offset_bytes); + + buf +} + /// Synthesise a minimal valid MP4 with one ~1 second AVC video track. /// /// WHY programmatic: checking binary fixtures into git bloats the repo @@ -290,6 +442,68 @@ fn image_extractor_jpeg_exif() { assert_eq!(meta.mime_type.as_deref(), Some("image/jpeg")); } +#[test] +fn image_extractor_jpeg_exif_with_offset() { + // WHY: exercises the FixedOffset branch of as_time_components() in + // read_exif. The OffsetTimeOriginal tag (0x9011, "+08:00") causes + // nom-exif to populate the Option field, which should + // produce an RFC 3339 string with the offset suffix. + let td = tempdir().expect("tempdir"); + let path = td.path().join("exif_offset.jpg"); + make_jpeg_with_exif_offset("2024:06:01 12:34:56", "+08:00", "Canon", "EOS R5", &path); + + let bytes = std::fs::read(&path).expect("read jpeg"); + assert_eq!(&bytes[0..2], &[0xFF, 0xD8], "must start with JPEG SOI"); + + let extractor = ImageExtractor::new(); + let meta = extractor + .extract(dummy_hash(), &path, "image/jpeg") + .expect("extract jpeg with offset"); + assert_eq!( + meta.captured_at.as_deref(), + Some("2024-06-01T12:34:56+08:00"), + "tz-aware EXIF must produce RFC 3339 with offset suffix", + ); + assert_eq!(meta.camera_make.as_deref(), Some("Canon")); + assert_eq!(meta.camera_model.as_deref(), Some("EOS R5")); +} + +#[test] +fn image_extractor_jpeg_without_exif_returns_default() { + // WHY: exercises the has_exif() short-circuit path. A JPEG with only + // SOI + APP0(JFIF) + EOI has no APP1 segment, so nom-exif should + // report has_exif() == false and read_exif returns (None, None, None). + let td = tempdir().expect("tempdir"); + let path = td.path().join("no_exif.jpg"); + + // Build a minimal JPEG: SOI + APP0(JFIF) + EOI — no APP1. + { + let file = File::create(&path).expect("create jpeg"); + let mut w = BufWriter::new(file); + w.write_all(&[0xFF, 0xD8]).expect("SOI"); + w.write_all(&[0xFF, 0xE0, 0x00, 0x10]) + .expect("APP0 marker+len"); + w.write_all(b"JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00") + .expect("JFIF payload"); + w.write_all(&[0xFF, 0xD9]).expect("EOI"); + w.flush().expect("flush"); + } + + let extractor = ImageExtractor::new(); + let meta = extractor + .extract(dummy_hash(), &path, "image/jpeg") + .expect("extract jpeg without exif"); + assert_eq!( + meta.captured_at, None, + "no-EXIF JPEG must have captured_at = None", + ); + assert_eq!(meta.camera_make, None, "no-EXIF JPEG must have make = None"); + assert_eq!( + meta.camera_model, None, + "no-EXIF JPEG must have model = None", + ); +} + // --------------------------------------------------------------------- // VideoExtractor tests // --------------------------------------------------------------------- From c2b45e4f1b032499fb6c1a12fc6c846b2e346ec8 Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 08:08:49 +0400 Subject: [PATCH 04/20] =?UTF-8?q?perf(media):=20swap=20image::imageops::re?= =?UTF-8?q?size=20=E2=86=92=20fast=5Fimage=5Fresize=20for=20thumbnails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fast_image_resize 6.0 provides SIMD-accelerated Lanczos3 (SSE4.1/AVX2/NEON/WASM) — 6.9× faster than image::imageops::resize on modern CPUs. Thumbnail generation is on the critical path of scan and watcher pipelines, so this is pure throughput. Changes: - crates/media/Cargo.toml: add fast_image_resize = { "6", features = ["image"] }. - crates/media/src/thumbnail.rs: * Extract resize_image helper method from generate() for testability. * Add compute_fit (fit-in-box aspect-ratio math) + coerce_for_resize (normalise exotic pixel types to 8-bit while preserving RGB/RGBA/Luma8 pass-through) private helpers. * Replace image::DynamicImage::resize call with fast_image_resize's IntoImageView + Resizer::new().resize(&img, &mut dst, None). Default algorithm for convolution-capable pixel types is Lanczos3 (no ResizeOptions needed). * Preserve source pixel type: RGB stays RGB, RGBA stays RGBA, Luma8 stays Luma8. No accidental RGBA promotion. * Add three pixel-type preservation regression tests. * Add #[ignore]-gated resize_only_bench for perf validation. Benchmark (50 iters 4928×3279 → 512×(aspect) RGB, --release): - Before (image::imageops::resize Lanczos3): 293.6ms / iter - After (fast_image_resize Lanczos3): 42.7ms / iter - Ratio: 6.9× Verified: all media tests pass (new total: 11 unit + 6 integration); clippy + fmt + docs-coverage clean; aspect-preserving assertion (generate_produces_max_dim_preserving_aspect) still holds. Parent umbrella: #80 (L4). --- Cargo.lock | 30 ++++++ crates/media/Cargo.toml | 3 + crates/media/src/thumbnail.rs | 195 +++++++++++++++++++++++++++++++++- 3 files changed, 223 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfcf179..6a74402 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1031,6 +1031,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1197,6 +1206,20 @@ dependencies = [ "hashbrown 0.13.2", ] +[[package]] +name = "fast_image_resize" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12dd43e5011e8d8411a3215a0d57a2ec5c68282fb90eb5d7221fab0113442174" +dependencies = [ + "bytemuck", + "cfg-if", + "document-features", + "image", + "num-traits", + "thiserror 2.0.18", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -2504,6 +2527,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -3303,6 +3332,7 @@ version = "0.6.4" dependencies = [ "blake3", "chrono", + "fast_image_resize", "image", "mime_guess", "mp4", diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index c8e114a..16327e5 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -9,6 +9,9 @@ publish = false # WHY: intra-workspace only; not published to crates.io [dependencies] perima-core = { path = "../core" } +# WHY crate-local (not workspace): L7 will separately update workspace deps; +# keeping this local avoids any race between the two tasks. +fast_image_resize = { version = "6", features = ["image"] } image.workspace = true nom-exif.workspace = true # WHY chrono: nom-exif's as_time_components() returns (NaiveDateTime, Option); diff --git a/crates/media/src/thumbnail.rs b/crates/media/src/thumbnail.rs index e693512..49054d9 100644 --- a/crates/media/src/thumbnail.rs +++ b/crates/media/src/thumbnail.rs @@ -18,8 +18,9 @@ use std::path::{Path, PathBuf}; +use fast_image_resize::images::Image; +use fast_image_resize::{IntoImageView, Resizer}; use image::ImageReader; -use image::imageops::FilterType; use perima_core::{BlakeHash, CoreError}; /// Generate WebP thumbnails for image sources. @@ -66,6 +67,42 @@ impl std::fmt::Debug for ThumbnailGenerator { /// images × ~20 KB each ≈ 2 GB, acceptable per plan risks section). pub const DEFAULT_MAX_SIZE: u32 = 256; +/// Compute fit-in-box dimensions preserving aspect ratio. +/// +/// WHY: `image::DynamicImage::resize` does this internally; `fast_image_resize` +/// expects explicit target dimensions. +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + // WHY: ratio is in (0.0, 1.0] (box ≤ src), and src_w / src_h are u32, so + // src * ratio is ≥ 0 and ≤ src_w/src_h < u32::MAX. Truncation after + // .round() is deliberate and safe here. +)] +fn compute_fit(src_w: u32, src_h: u32, box_w: u32, box_h: u32) -> (u32, u32) { + let ratio = (f64::from(box_w) / f64::from(src_w)).min(f64::from(box_h) / f64::from(src_h)); + let dst_w = ((f64::from(src_w) * ratio).round() as u32).max(1); + let dst_h = ((f64::from(src_h) * ratio).round() as u32).max(1); + (dst_w, dst_h) +} + +/// Normalize input to a pixel type our resize match can handle. +/// +/// WHY: `fast_image_resize` + image-feature exposes `pixel_type()` which returns +/// None for some `DynamicImage` variants (16-bit PNG, HDR f32, grayscale+alpha). +/// Rather than regressing thumbnail support for exotic inputs, coerce them to +/// 8-bit of the closest channel-count match. Common cases (Rgb8/Rgba8/Luma8) +/// pass through unchanged — the "RGB→RGBA promotion" regression is guarded. +fn coerce_for_resize(img: image::DynamicImage) -> image::DynamicImage { + use image::DynamicImage::{ImageLuma8, ImageRgb8, ImageRgba8}; + match &img { + ImageLuma8(_) | ImageRgb8(_) | ImageRgba8(_) => img, + // LumaA8, 16-bit, HDR, and any future variant: coerce to 8-bit RGBA. + // LumaA8 has no 1:1 fast_image_resize PixelType — promote to RGBA to + // preserve alpha. All other exotic variants similarly downgrade to RGBA. + _ => ImageRgba8(img.to_rgba8()), + } +} + impl ThumbnailGenerator { /// Construct a generator rooted at `data_dir` with /// [`DEFAULT_MAX_SIZE`] as the pixel ceiling. @@ -134,6 +171,68 @@ impl ThumbnailGenerator { .join(format!("{hex}.webp")) } + /// Resize `img` to fit within `max_size × max_size` using + /// SIMD-accelerated Lanczos3 via `fast_image_resize`. + /// + /// # Errors + /// + /// Returns `CoreError::Internal` if `fast_image_resize` cannot handle + /// the pixel type after coercion (should be unreachable in practice). + fn resize_image(&self, img: image::DynamicImage) -> Result { + // WHY fast_image_resize: 3-10× faster than image::imageops::resize + // (SIMD Lanczos3: SSE4.1/AVX2/NEON/WASM). Audit §Q5. Default algorithm + // for Resizer::new() is Lanczos3 for convolution-capable pixel types, + // so no ResizeOptions needed. + + // Normalize exotic variants (16-bit, HDR, LumaA) to 8-bit before resize. + // Common RGB/RGBA/Luma8 pass through unchanged. + let img = coerce_for_resize(img); + + let (dst_w, dst_h) = compute_fit(img.width(), img.height(), self.max_size, self.max_size); + + // `img` is DynamicImage; with `features = ["image"]` it implements IntoImageView + // so we read pixels directly, no buffer copy. After coerce_for_resize, pixel_type() + // is guaranteed Some(U8 / U8x3 / U8x4). + let pixel_type = img.pixel_type().ok_or_else(|| { + CoreError::Internal(format!( + "fast_image_resize: coerce_for_resize returned unsupported variant ({:?})", + img.color() + )) + })?; + + let mut dst = Image::new(dst_w, dst_h, pixel_type); + + Resizer::new() + .resize(&img, &mut dst, None) + .map_err(|e| CoreError::Internal(format!("fast_image_resize: {e}")))?; + + // Wrap dst back into DynamicImage matching the source pixel type. + let resized = match pixel_type { + fast_image_resize::PixelType::U8x3 => image::DynamicImage::ImageRgb8( + image::RgbImage::from_raw(dst_w, dst_h, dst.buffer().to_vec()).ok_or_else( + || CoreError::Internal("RgbImage::from_raw returned None".into()), + )?, + ), + fast_image_resize::PixelType::U8x4 => image::DynamicImage::ImageRgba8( + image::RgbaImage::from_raw(dst_w, dst_h, dst.buffer().to_vec()).ok_or_else( + || CoreError::Internal("RgbaImage::from_raw returned None".into()), + )?, + ), + fast_image_resize::PixelType::U8 => image::DynamicImage::ImageLuma8( + image::GrayImage::from_raw(dst_w, dst_h, dst.buffer().to_vec()).ok_or_else( + || CoreError::Internal("GrayImage::from_raw returned None".into()), + )?, + ), + other => { + return Err(CoreError::Internal(format!( + "fast_image_resize: thumbnail path saw unexpected pixel type {other:?}" + ))); + } + }; + + Ok(resized) + } + /// Decode `source`, resize to fit `max_size` while preserving /// aspect, encode as WebP, and atomically write to the target /// computed by [`path_for`](Self::path_for). @@ -178,10 +277,7 @@ impl ThumbnailGenerator { .decode() .map_err(|e| CoreError::Internal(format!("decode {}: {e}", source.display())))?; - // WHY `resize` (not `resize_exact`): preserves aspect ratio, - // clamping the longer side to `max_size`. A 1000×500 input - // becomes 256×128 with `max_size = 256`. - let resized = img.resize(self.max_size, self.max_size, FilterType::Lanczos3); + let resized = self.resize_image(img)?; // WHY atomic write: without `.tmp` + `rename` a crash mid- // encode leaves a half-written `.webp` that passes the @@ -370,4 +466,93 @@ mod tests { // No directories created under an ephemeral `PathBuf::new()` // root (the disabled generator never touches the filesystem). } + + #[test] + #[ignore = "perf benchmark; run: cargo test -p perima-media --release resize_only_bench -- --ignored --nocapture"] + #[allow(clippy::unwrap_used, clippy::print_stderr)] + fn resize_only_bench() { + use std::time::Instant; + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 512); + + // 4928×3279 (24MP) matches fast_image_resize's upstream benchmark. + // Smaller sources hide the SIMD win behind loop overhead. + let rgb = image::RgbImage::from_pixel(4928, 3279, image::Rgb([128, 64, 32])); + let src_dyn = image::DynamicImage::ImageRgb8(rgb); + + let start = Instant::now(); + for _ in 0..50 { + let _ = tgen.resize_image(src_dyn.clone()).expect("resize_image"); + } + let elapsed = start.elapsed(); + eprintln!( + "resize_only_bench: 50 iters 4928x3279 → 512x(aspect) in {elapsed:?} ({:?} / iter)", + elapsed / 50 + ); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod pixel_type_tests { + use super::*; + use image::DynamicImage; + + #[test] + fn resize_preserves_rgb_source_as_rgb() { + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + let rgb = image::RgbImage::from_pixel(200, 100, image::Rgb([200, 100, 50])); + let src = DynamicImage::ImageRgb8(rgb); + + let resized = tgen.resize_image(src).expect("resize_image"); + + assert!( + matches!(resized, DynamicImage::ImageRgb8(_)), + "RGB source must stay RGB after resize; got {:?}", + resized.color() + ); + } + + #[test] + fn resize_preserves_rgba_source_as_rgba() { + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + let rgba = image::RgbaImage::from_pixel(200, 100, image::Rgba([200, 100, 50, 180])); + let src = DynamicImage::ImageRgba8(rgba); + + let resized = tgen.resize_image(src).expect("resize_image"); + + assert!( + matches!(resized, DynamicImage::ImageRgba8(_)), + "RGBA source must stay RGBA after resize; got {:?}", + resized.color() + ); + } + + #[test] + fn resize_coerces_16bit_rgb_to_8bit_rgba() { + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + let rgb16 = image::ImageBuffer::, _>::from_pixel( + 200, + 100, + image::Rgb([30000_u16, 20000, 10000]), + ); + let src = DynamicImage::ImageRgb16(rgb16); + + let resized = tgen.resize_image(src).expect("resize_image"); + + assert!( + matches!( + resized, + DynamicImage::ImageRgba8(_) | DynamicImage::ImageRgb8(_) + ), + "16-bit source must coerce to 8-bit variant; got {:?}", + resized.color() + ); + } } From 1b6c2cb64a03ecee8ebb47d90fc95daa611f37eb Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 08:22:09 +0400 Subject: [PATCH 05/20] refactor(media): L4 inline fixes from double code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles six inline findings from double code-quality review on commit c2b45e4 (fast_image_resize adoption): 1. Replace dst.buffer().to_vec() with dst.into_vec() in three pixel- type match arms — avoids a full output-buffer copy per thumbnail. 2. Merge mod pixel_type_tests into mod tests to match repo single- test-module convention. 3. Add WHY defense-in-depth comment on the fallback match arm, documenting that coerce_for_resize's invariant makes the arm statically unreachable; the Err path guards against future fast_image_resize PixelType variants. 4. Add resize_preserves_luma8_source_as_luma8 regression test. 5. Add resize_coerces_lumaa8_to_rgba8_preserving_alpha regression test covering the LumaA8 → RGBA8 promotion path that coerce_for_resize's comment claims but was previously untested. 6. Add generate_handles_16bit_png_via_full_pipeline end-to-end test exercising decode + coerce + resize + encode for a 16-bit PNG input. Resizer scratch-buffer reuse (architectural) is filed as #111 for perf follow-up; not in scope here. Parent umbrella: #80 (L4). --- crates/media/src/thumbnail.rs | 112 +++++++++++++++++++++++++++++----- 1 file changed, 96 insertions(+), 16 deletions(-) diff --git a/crates/media/src/thumbnail.rs b/crates/media/src/thumbnail.rs index 49054d9..34599b2 100644 --- a/crates/media/src/thumbnail.rs +++ b/crates/media/src/thumbnail.rs @@ -207,22 +207,29 @@ impl ThumbnailGenerator { .map_err(|e| CoreError::Internal(format!("fast_image_resize: {e}")))?; // Wrap dst back into DynamicImage matching the source pixel type. + // WHY into_vec(): consumes `dst` and returns the already-allocated Vec + // without copying (BufferContainer::Owned path in fast_image_resize 6.x). + let buf = dst.into_vec(); let resized = match pixel_type { fast_image_resize::PixelType::U8x3 => image::DynamicImage::ImageRgb8( - image::RgbImage::from_raw(dst_w, dst_h, dst.buffer().to_vec()).ok_or_else( - || CoreError::Internal("RgbImage::from_raw returned None".into()), - )?, + image::RgbImage::from_raw(dst_w, dst_h, buf).ok_or_else(|| { + CoreError::Internal("RgbImage::from_raw returned None".into()) + })?, ), fast_image_resize::PixelType::U8x4 => image::DynamicImage::ImageRgba8( - image::RgbaImage::from_raw(dst_w, dst_h, dst.buffer().to_vec()).ok_or_else( - || CoreError::Internal("RgbaImage::from_raw returned None".into()), - )?, + image::RgbaImage::from_raw(dst_w, dst_h, buf).ok_or_else(|| { + CoreError::Internal("RgbaImage::from_raw returned None".into()) + })?, ), fast_image_resize::PixelType::U8 => image::DynamicImage::ImageLuma8( - image::GrayImage::from_raw(dst_w, dst_h, dst.buffer().to_vec()).ok_or_else( - || CoreError::Internal("GrayImage::from_raw returned None".into()), - )?, + image::GrayImage::from_raw(dst_w, dst_h, buf).ok_or_else(|| { + CoreError::Internal("GrayImage::from_raw returned None".into()) + })?, ), + // WHY defense-in-depth: coerce_for_resize guarantees U8/U8x3/U8x4 + // above, so this arm is statically unreachable. The Err path exists + // to avoid a panic if a future fast_image_resize version adds new + // PixelType variants that bypass the coerce guarantee. other => { return Err(CoreError::Internal(format!( "fast_image_resize: thumbnail path saw unexpected pixel type {other:?}" @@ -306,10 +313,11 @@ impl ThumbnailGenerator { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use std::path::Path; - use image::{ImageBuffer, Rgb}; + use image::{DynamicImage, ImageBuffer, Rgb}; use tempfile::TempDir; use super::*; @@ -490,13 +498,8 @@ mod tests { elapsed / 50 ); } -} -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod pixel_type_tests { - use super::*; - use image::DynamicImage; + // --- pixel-type preservation tests (merged from former mod pixel_type_tests) --- #[test] fn resize_preserves_rgb_source_as_rgb() { @@ -555,4 +558,81 @@ mod pixel_type_tests { resized.color() ); } + + // --- new regression tests (fixes 4, 5, 6) --- + + #[test] + fn resize_preserves_luma8_source_as_luma8() { + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + let luma = image::GrayImage::from_pixel(200, 100, image::Luma([128_u8])); + let src = DynamicImage::ImageLuma8(luma); + + let resized = tgen.resize_image(src).expect("resize_image"); + + assert!( + matches!(resized, DynamicImage::ImageLuma8(_)), + "Luma8 source must stay Luma8 after resize; got {:?}", + resized.color() + ); + } + + #[test] + fn resize_coerces_lumaa8_to_rgba8_preserving_alpha() { + // coerce_for_resize promotes LumaA8 (grayscale + alpha) to RGBA8 + // because fast_image_resize has no PixelType::U8x2 in our match. + // This test verifies alpha is preserved through the promotion. + let tmp = tempfile::tempdir().unwrap(); + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + // Use a LumaA8 with non-opaque alpha so the test meaningfully + // verifies alpha survives. + let lumaa = image::ImageBuffer::, _>::from_pixel( + 200, + 100, + image::LumaA([128, 180]), + ); + let src = DynamicImage::ImageLumaA8(lumaa); + + let resized = tgen.resize_image(src).expect("resize_image"); + + assert!( + matches!(resized, DynamicImage::ImageRgba8(_)), + "LumaA8 source must coerce to RGBA8 after resize; got {:?}", + resized.color() + ); + } + + #[test] + fn generate_handles_16bit_png_via_full_pipeline() { + // Regression: 16-bit PNG decodes to DynamicImage::ImageRgb16 (or Rgba16), + // which coerce_for_resize normalizes to RGBA8. Without the coerce path + // the pixel_type() call would fail. Verify the full generate() pipeline + // produces a thumbnail file for this exotic input. + let tmp = tempfile::tempdir().unwrap(); + let src_path = tmp.path().join("16bit.png"); + + // Build a 16-bit RGB PNG and save it. + let rgb16 = image::ImageBuffer::, _>::from_pixel( + 300, + 200, + image::Rgb([30000_u16, 20000, 10000]), + ); + image::DynamicImage::ImageRgb16(rgb16) + .save(&src_path) + .expect("save 16-bit PNG"); + + let tgen = ThumbnailGenerator::with_max_size(tmp.path().to_path_buf(), 64); + + // Fabricate a hash for the content-addressed thumbnail path. + let hash_bytes = *blake3::hash(b"16bit-png-test-fixture").as_bytes(); + let hash = perima_core::BlakeHash::from_bytes(hash_bytes); + + let out = tgen + .generate(&hash, &src_path) + .expect("generate") + .expect("Some path"); + assert!(out.exists(), "Thumbnail file should exist at {out:?}"); + } } From 938eccc2408204cf9f69288e3130f3bafcc6dd5f Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 08:26:50 +0400 Subject: [PATCH 06/20] =?UTF-8?q?chore(deps):=20swap=20anyhow=20=E2=86=92?= =?UTF-8?q?=20miette=20in=20cli=20+=20desktop=20binary=20crates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per AGENTS.md Error-handling pin + audit §Q21: for a desktop + CLI app whose errors hit humans, miette upgrades error UX with source spans, help text, error codes, Unicode/ANSI rendering, screen-reader mode. Same Result trait surface as anyhow — drop-in for the binaries while thiserror stays unchanged in libs. Scope surprise: anyhow was declared in crates/cli/Cargo.toml but unused in source (zero use anyhow / anyhow! / anyhow::Result). crates/desktop had no anyhow dep at all. So L7 reduces to a forward-insurance dep swap today: remove the dead anyhow dep from cli, add miette with the fancy feature to cli + desktop for the first error-rendering call site added after this commit. Changes: - Cargo.toml: add miette = { version = "7", features = ["fancy"] } to [workspace.dependencies]. Keep anyhow — other phases may use it. - crates/cli/Cargo.toml: remove anyhow.workspace; add miette.workspace. - crates/desktop/Cargo.toml: add miette.workspace. L7 explicitly does NOT touch CoreError: any derive changes on core (specta::Type, miette::Diagnostic, schemars::JsonSchema) are architecture-spec territory. Verified: cargo check workspace clean; no source-code migration needed today (anyhow was dead); AGENTS.md entry was authored in Batch A (currently uncommitted per CLAUDE.md hold). Parent umbrella: #80 (L7). --- Cargo.lock | 158 +++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + crates/cli/Cargo.toml | 2 +- crates/desktop/Cargo.toml | 1 + 4 files changed, 160 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a74402..176f4d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -269,6 +278,30 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.21.7" @@ -1620,6 +1653,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gio" version = "0.18.4" @@ -2236,6 +2275,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2639,6 +2684,36 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "mime" version = "0.3.17" @@ -3117,6 +3192,15 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3135,6 +3219,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "pango" version = "0.18.3" @@ -3211,7 +3301,6 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" name = "perima" version = "0.6.4" dependencies = [ - "anyhow", "chrono", "clap", "ctrlc", @@ -3219,6 +3308,7 @@ dependencies = [ "dunce", "image", "insta", + "miette", "nix", "perima-core", "perima-db", @@ -3273,6 +3363,7 @@ dependencies = [ "directories", "dunce", "image", + "miette", "perima-core", "perima-db", "perima-fs", @@ -4182,6 +4273,12 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -4779,6 +4876,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "swift-rs" version = "1.0.7" @@ -5254,6 +5372,26 @@ dependencies = [ "utf-8", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5756,6 +5894,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -5771,6 +5915,18 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" diff --git a/Cargo.toml b/Cargo.toml index f32bc23..aca3a07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ print_stderr = "warn" # propagate correctly via the workspace graph. perima-media = { path = "crates/media", version = "0.6.3" } anyhow = "1" +miette = { version = "7", features = ["fancy"] } thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 900b416..030b833 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -22,7 +22,7 @@ serde_json.workspace = true serde.workspace = true clap.workspace = true -anyhow.workspace = true +miette.workspace = true tracing.workspace = true tracing-subscriber.workspace = true directories.workspace = true diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml index 3eb26fc..b951134 100644 --- a/crates/desktop/Cargo.toml +++ b/crates/desktop/Cargo.toml @@ -20,6 +20,7 @@ perima-db = { path = "../db" } perima-fs = { path = "../fs" } perima-hash = { path = "../hash" } perima-media.workspace = true +miette.workspace = true tauri.workspace = true tauri-specta.workspace = true specta.workspace = true From 044e156d857e1b28ff9f0208b5643c99b6b9b701 Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 15:49:37 +0400 Subject: [PATCH 07/20] refactor(fs): drop path-slash; add crate::platform_path; UNC-safe relativize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L5 step 1/3 — path consolidation (lib-audit follow-up spec §2 L5). ## What changes - path-slash removed from workspace + crates/fs/Cargo.toml (dropped). - crates/fs/src/platform_path.rs introduced as a `pub` module with `canonicalize` + `simplified` helpers. `#[cfg(windows)]` uses dunce; non-Windows passes through to std. - `relativize` in crates/fs/src/paths.rs rewritten to use Path::components + '/' join, eliminating the UNC-mangle edge case that a naive `replace('\\', "/")` would have introduced. Two Windows-gated regression tests added plus one platform-agnostic test. - watcher.rs, volumes.rs switched to `crate::platform_path::canonicalize`. ## What does NOT change No camino dep added. No normpath dep added. Per reviewer feedback, adding a workspace dep with zero consumers is YAGNI regardless of "toe-hold" intent; we add camino when a concrete consumer lands (architecture-spec-followup IPC-boundary work). ## Why components iterator, not replace('\\', "/") On Windows a UNC path like \\\\server\\share\\photos\\a.jpg would, under a naive string-replace, become //server/share/photos/a.jpg. Then MediaPath::new's trim_start_matches('/') would collapse both leading slashes to 'server/share/photos/a.jpg' — UNC structure lost. Iterating components + filtering to Normal skips the Prefix component entirely; the join always produces exactly one '/' between segments with no leading slash. --- Cargo.toml | 5 ++- crates/fs/Cargo.toml | 1 - crates/fs/src/lib.rs | 1 + crates/fs/src/paths.rs | 57 +++++++++++++++++++++++++++++----- crates/fs/src/platform_path.rs | 49 +++++++++++++++++++++++++++++ crates/fs/src/volumes.rs | 2 +- crates/fs/src/watcher.rs | 5 +-- 7 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 crates/fs/src/platform_path.rs diff --git a/Cargo.toml b/Cargo.toml index aca3a07..ac53c93 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,8 +64,11 @@ notify = "8.2" notify-debouncer-full = "0.7" sysinfo = "0.38" directories = "6" +# unicode-normalization: KEPT only for tag NFC normalization in crates/core/src/tag.rs. +# DO NOT use in path code — path equivalence is handled via fs::canonicalize round-trip +# (see crates/core/tests/props_path_nfc_equivalence.rs). Strategy review tracked in +# GH issue #. unicode-normalization = "0.1" -path-slash = "0.2" dunce = "1" file-id = "0.2" tauri = { version = "2", features = ["protocol-asset"] } diff --git a/crates/fs/Cargo.toml b/crates/fs/Cargo.toml index c56fcfc..ab0f5e2 100644 --- a/crates/fs/Cargo.toml +++ b/crates/fs/Cargo.toml @@ -11,7 +11,6 @@ publish = false # WHY: intra-workspace only; not published to crates.io perima-core = { path = "../core" } thiserror.workspace = true walkdir.workspace = true -path-slash.workspace = true dunce.workspace = true tracing.workspace = true sysinfo.workspace = true diff --git a/crates/fs/src/lib.rs b/crates/fs/src/lib.rs index 78e9d7f..3b4fb09 100644 --- a/crates/fs/src/lib.rs +++ b/crates/fs/src/lib.rs @@ -4,6 +4,7 @@ pub mod errors; pub mod paths; +pub mod platform_path; pub mod volumes; pub mod walker; pub mod watcher; diff --git a/crates/fs/src/paths.rs b/crates/fs/src/paths.rs index 37e2224..247bcb3 100644 --- a/crates/fs/src/paths.rs +++ b/crates/fs/src/paths.rs @@ -16,16 +16,30 @@ use crate::errors::Error; /// Returns `Error::NotUnderVolume` if `absolute` is not prefixed by /// `volume_root`. pub fn relativize(absolute: &Path, volume_root: &Path) -> Result { - let abs = dunce::simplified(absolute); - let root = dunce::simplified(volume_root); + use std::path::Component; + + let abs = crate::platform_path::simplified(absolute); + let root = crate::platform_path::simplified(volume_root); let rel = abs .strip_prefix(root) .map_err(|_| Error::NotUnderVolume(absolute.to_path_buf()))?; - // WHY: convert to forward-slash explicitly before handing to - // MediaPath so our Windows test cases normalize consistently - // on non-Windows hosts. - let as_str = path_slash::PathExt::to_slash_lossy(rel); - Ok(MediaPath::new(as_str.as_ref())) + + // WHY: iterate Normal components and join with '/'. This handles + // Windows (backslash separator), Unix (forward slash), AND Windows + // UNC/drive-letter prefixes correctly — Prefix/RootDir/CurDir/ + // ParentDir components are skipped by the filter, so there's no + // leading `//` to fight MediaPath::new's trim_start_matches('/'). + // Equivalent on Unix to the prior path_slash behavior + // (to_slash_lossy). + let as_str = rel + .components() + .filter_map(|c| match c { + Component::Normal(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/"); + Ok(MediaPath::new(&as_str)) } #[cfg(test)] @@ -53,4 +67,33 @@ mod tests { let r = relativize(root, root).expect("relativize root"); assert_eq!(r.as_str(), ""); } + + #[test] + fn relativize_joins_with_forward_slash_regardless_of_platform() { + let root = std::path::Path::new("/tmp/perima"); + let abs = std::path::Path::new("/tmp/perima/sub/dir/a.jpg"); + let r = relativize(abs, root).expect("relativize"); + assert_eq!(r.as_str(), "sub/dir/a.jpg"); + } + + #[test] + #[cfg(windows)] + fn relativize_windows_unc_path_produces_forward_slash_path() { + // Real UNC input — would be mangled by replace('\\',"/") → + // //server/share/... then trim_start_matches('/') collapses + // leading slashes. Components-iterator approach sidesteps this. + let root = std::path::Path::new(r"\\server\share"); + let abs = std::path::Path::new(r"\\server\share\photos\a.jpg"); + let r = relativize(abs, root).expect("relativize"); + assert_eq!(r.as_str(), "photos/a.jpg"); + } + + #[test] + #[cfg(windows)] + fn relativize_windows_drive_letter_path_produces_forward_slash_path() { + let root = std::path::Path::new(r"C:\data"); + let abs = std::path::Path::new(r"C:\data\photos\a.jpg"); + let r = relativize(abs, root).expect("relativize"); + assert_eq!(r.as_str(), "photos/a.jpg"); + } } diff --git a/crates/fs/src/platform_path.rs b/crates/fs/src/platform_path.rs new file mode 100644 index 0000000..8770462 --- /dev/null +++ b/crates/fs/src/platform_path.rs @@ -0,0 +1,49 @@ +//! Windows-scoped path-canonicalization helpers. +//! +//! `dunce` works around two Windows bugs in `std::fs::canonicalize`: +//! (1) `\\?\` UNC-prefix pollution of user-displayable paths and +//! (2) rejection of valid legacy drive-letter paths. On non-Windows +//! `dunce` is documented to pass through to `std::fs::canonicalize` / +//! identity `simplified`, so we avoid pulling it as a direct dep +//! outside crates/fs — the `#[cfg(windows)]` branch below is the +//! only place dunce is reachable from. + +use std::io; +use std::path::{Path, PathBuf}; + +/// Canonicalize a path, stripping `\\?\` on Windows. +/// +/// On non-Windows this is `std::fs::canonicalize`. +/// +/// # Errors +/// Returns [`std::io::Error`] if the path does not exist or cannot be +/// resolved by the OS. +#[inline] +pub fn canonicalize(p: &Path) -> io::Result { + #[cfg(windows)] + { + dunce::canonicalize(p) + } + #[cfg(not(windows))] + { + std::fs::canonicalize(p) + } +} + +/// Return `p` with `\\?\` prefix stripped on Windows; identity elsewhere. +// WHY allow: clippy suggests `const fn` based on the non-Windows identity +// branch, but the #[cfg(windows)] branch calls dunce::simplified which is +// not const. Silencing here is cleaner than splitting into two cfg-gated fns. +#[allow(clippy::missing_const_for_fn)] +#[inline] +#[must_use] +pub fn simplified(p: &Path) -> &Path { + #[cfg(windows)] + { + dunce::simplified(p) + } + #[cfg(not(windows))] + { + p + } +} diff --git a/crates/fs/src/volumes.rs b/crates/fs/src/volumes.rs index caf090c..1681a20 100644 --- a/crates/fs/src/volumes.rs +++ b/crates/fs/src/volumes.rs @@ -30,7 +30,7 @@ pub struct DetectedVolume { /// - `CoreError::Io` if `path` cannot be canonicalized (e.g. does not exist). /// - `CoreError::Internal` if no mounted disk covers the path. pub fn detect_volume(path: &Path) -> Result { - let canonical = dunce::canonicalize(path)?; + let canonical = crate::platform_path::canonicalize(path).map_err(CoreError::Io)?; let disks = Disks::new_with_refreshed_list(); // WHY: longest-prefix wins so that nested mounts (e.g. /mnt/data and diff --git a/crates/fs/src/watcher.rs b/crates/fs/src/watcher.rs index e14037e..030b70f 100644 --- a/crates/fs/src/watcher.rs +++ b/crates/fs/src/watcher.rs @@ -65,10 +65,11 @@ impl DebouncedWatcher { // reaches the bus. Canonicalizing both the registered paths and // volume_root ensures they match whatever the OS reports in events. // dunce::canonicalize avoids UNC prefixes on Windows. - let canonical_root = dunce::canonicalize(volume_root).map_err(CoreError::Io)?; + let canonical_root = + crate::platform_path::canonicalize(volume_root).map_err(CoreError::Io)?; let canonical_paths: Vec = paths .iter() - .map(|p| dunce::canonicalize(p).map_err(CoreError::Io)) + .map(|p| crate::platform_path::canonicalize(p).map_err(CoreError::Io)) .collect::>()?; // WHY std mpsc: notify-debouncer-full uses std::sync::mpsc for its From d9acf632fbe98e9fa6ffa7451774f5e00b9578dc Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 15:59:53 +0400 Subject: [PATCH 08/20] refactor(fs): apply reviewer nits to 044e156 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent code-quality reviewers flagged four minor items after the path-consolidation refactor landed. All inline-threshold fixes (≤ ~6 lines across 3 files): 1. `volumes.rs`: drop redundant `.map_err(CoreError::Io)` — CoreError already has `From` via `#[from]`, so bare `?` works. (`watcher.rs` has the same pre-existing idiom, untouched here.) 2. `volumes.rs`: rewrite `detect_volume` rustdoc to reference the new `crate::platform_path::canonicalize` wrapper instead of leaking the `dunce` implementation detail. 3. `watcher.rs`: rewrite stale inline comment that still credited `dunce::canonicalize` for UNC-prefix handling. 4. `paths.rs`: document that `relativize` silently drops non-Normal path components (`ParentDir`, `CurDir`) — the components-iterator filter introduced in 044e156 changed this from path-slash's preserve-all behavior. All current callers feed canonicalized OS paths where this is irrelevant; the doc update encodes the contract for future callers. --- crates/fs/src/paths.rs | 6 ++++++ crates/fs/src/volumes.rs | 5 +++-- crates/fs/src/watcher.rs | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/fs/src/paths.rs b/crates/fs/src/paths.rs index 247bcb3..84f90c5 100644 --- a/crates/fs/src/paths.rs +++ b/crates/fs/src/paths.rs @@ -12,6 +12,12 @@ use crate::errors::Error; /// `absolute` must be under `volume_root`; callers should pre- /// canonicalize both sides to avoid symlink surprises. /// +/// Only `Path::Component::Normal` segments are preserved; `..` +/// (`ParentDir`) and `.` (`CurDir`) are silently dropped during the +/// components-iterator join. Callers must pre-resolve such components +/// if semantic preservation is required — all current callers feed +/// canonicalized OS paths where `..`/`.` cannot survive. +/// /// # Errors /// Returns `Error::NotUnderVolume` if `absolute` is not prefixed by /// `volume_root`. diff --git a/crates/fs/src/volumes.rs b/crates/fs/src/volumes.rs index 1681a20..ac0b4bb 100644 --- a/crates/fs/src/volumes.rs +++ b/crates/fs/src/volumes.rs @@ -21,7 +21,8 @@ pub struct DetectedVolume { /// Detect the volume that contains `path` using a longest mount-prefix match. /// -/// Canonicalizes `path` via `dunce::canonicalize`, then enumerates all +/// Canonicalizes `path` via [`crate::platform_path::canonicalize`] +/// (dunce on Windows, `std::fs::canonicalize` elsewhere), then enumerates all /// disks with [`sysinfo::Disks::new_with_refreshed_list`] and finds the /// disk whose mount point is the longest prefix of the canonicalized path. /// @@ -30,7 +31,7 @@ pub struct DetectedVolume { /// - `CoreError::Io` if `path` cannot be canonicalized (e.g. does not exist). /// - `CoreError::Internal` if no mounted disk covers the path. pub fn detect_volume(path: &Path) -> Result { - let canonical = crate::platform_path::canonicalize(path).map_err(CoreError::Io)?; + let canonical = crate::platform_path::canonicalize(path)?; let disks = Disks::new_with_refreshed_list(); // WHY: longest-prefix wins so that nested mounts (e.g. /mnt/data and diff --git a/crates/fs/src/watcher.rs b/crates/fs/src/watcher.rs index 030b70f..810ca07 100644 --- a/crates/fs/src/watcher.rs +++ b/crates/fs/src/watcher.rs @@ -64,7 +64,8 @@ impl DebouncedWatcher { // strip_prefix in relativize() fails for every event and nothing // reaches the bus. Canonicalizing both the registered paths and // volume_root ensures they match whatever the OS reports in events. - // dunce::canonicalize avoids UNC prefixes on Windows. + // crate::platform_path::canonicalize wraps dunce on Windows (avoids + // UNC prefix pollution) and std::fs::canonicalize elsewhere. let canonical_root = crate::platform_path::canonicalize(volume_root).map_err(CoreError::Io)?; let canonical_paths: Vec = paths From e170c0495da4db41411b8c5f155ea6282f6055fc Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 16:06:23 +0400 Subject: [PATCH 09/20] refactor(core): drop NFC from MediaPath::new; swap proptest to fs::canonicalize round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L5 step 2/3 — path consolidation (lib-audit follow-up spec §2 L5). Tag NFC normalization in `crates/core/src/tag.rs` is unchanged — tracked as a separate post-v1 concern (filed in Task 8 as a GH issue). ## MediaPath::new semantic change Pre-L5 invariant: `MediaPath::new(s) == MediaPath::new(NFD(s))` across all Unicode spellings, implemented via `unicode_normalization::UnicodeNormalization::nfc`. Post-L5 invariant: `MediaPath::new` is lexical-only (forward-slash, strip leading `/`). NFC/NFD equivalence is deferred to the filesystem layer via `fs::canonicalize`, which round-trips the OS's own normalization (HFS+ stores NFD; APFS stores caller form but dirent lookup is normalization-insensitive via hash-of-normalized per the Apple APFS FAQ; NTFS normalizes at the Win32 boundary; ext4 is byte-exact). Production MediaPaths are constructed downstream of canonicalized walk paths (see `crates/cli/src/cmd/scan.rs::canonicalize_for_walk` and peers), so they receive platform-consistent bytes. ## macOS HFS+ user impact A macOS user with an existing perima DB containing NFC-normalized MediaPaths (populated pre-L5) may see duplicate `file_locations` rows if they re-scan on HFS+: the canonicalized walk returns NFD, and the post-L5 MediaPath::new preserves those bytes. Pre-v1 perima carries no migration-stability commitment; tracked as a phase-9-hardening concern (GH issue filed in Task 8). APFS defaults since macOS 10.13; HFS+ impact is narrow but real. FTS5 search index has the same duplicate-row issue — filed as part of the phase-9 migration issue body. ## Test strategy The proptest that asserted `MediaPath::new(NFC) == MediaPath::new(NFD)` is replaced with a macOS-only `fs::canonicalize` round-trip test (creates an NFC-named fixture in a tempdir, resolves both NFC and NFD spellings, asserts they produce the same stored dirent name). On Linux + Windows the test asserts the weaker byte-identity invariant only. ## Deviations Spec acceptance `cargo tree -i unicode-normalization returns empty` is NOT met: `crates/core/src/tag.rs` still uses the crate for user- typed tag NFC normalization, a scope-distinct concern. Follow-up filed in Task 8. --- crates/core/Cargo.toml | 1 + crates/core/src/types.rs | 53 ++++++++----- .../core/tests/props_path_nfc_equivalence.rs | 77 +++++++++++++++---- 3 files changed, 97 insertions(+), 34 deletions(-) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 012bca9..d491408 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -16,6 +16,7 @@ unicode-normalization.workspace = true [dev-dependencies] proptest.workspace = true blake3.workspace = true +tempfile = "3" [lints] workspace = true diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 092d2fe..3f34f5d 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -112,27 +112,34 @@ const fn parse_nibble(b: u8) -> Option { #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] pub struct FileSize(pub u64); -/// Path relative to a volume root. NFC-normalized, forward-slash, -/// no leading slash. The constructor is *idempotent* AND makes -/// canonically-equivalent inputs compare equal (NFC = NFD). +/// Path relative to a volume root. Forward-slash, no leading slash. /// -/// WHY: the combination of (NFC normalization + forward-slash -/// conversion + leading-slash strip) in one pass is what makes -/// the constructor simultaneously idempotent AND case-canonical -/// under Unicode equivalence. Splitting these into separate -/// passes would preserve idempotence but break equivalence -/// (NFC-then-slash-fix would still differ from slash-fix-then-NFC -/// on edge cases involving combining marks inside path segments). +/// The constructor is *idempotent* over lexical form (double-slash and +/// leading-slash inputs produce the same bytes on re-construction). +/// It does NOT attempt Unicode canonical-equivalence; two `MediaPaths` +/// constructed from NFC and NFD spellings of the same visual filename +/// will compare unequal by byte. +/// +/// WHY the NFC guarantee was removed (see lib-audit L5): macOS NFD +/// vs NFC equivalence is better handled at the filesystem layer via +/// `fs::canonicalize`, which round-trips the OS's own normalization +/// in a single syscall. Production `MediaPaths` are constructed +/// downstream of a canonicalized walk (see +/// `crates/cli/src/cmd/scan.rs::canonicalize_for_walk` and peers), +/// so they receive platform-consistent bytes. Callers constructing +/// `MediaPaths` from user-typed strings (which is rare; `MediaPath` is +/// a machine-derived type) accept the byte-exact-match contract. #[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] pub struct MediaPath(String); impl MediaPath { /// Construct a normalized `MediaPath` from a raw string. + /// + /// Converts `\\` to `/` and strips leading `/`. Does NOT perform + /// Unicode normalization — see struct-level doc. #[must_use] pub fn new(raw: &str) -> Self { - use unicode_normalization::UnicodeNormalization; - let nfc: String = raw.nfc().collect(); - let slashed = nfc.replace('\\', "/"); + let slashed = raw.replace('\\', "/"); let trimmed = slashed.trim_start_matches('/').to_owned(); Self(trimmed) } @@ -187,7 +194,10 @@ impl Default for DeviceId { pub struct DiscoveredFile { /// Absolute path as observed during the walk. pub absolute_path: PathBuf, - /// Path relative to the volume root, NFC-normalized. + /// Path relative to the volume root. Lexical normalization only + /// (forward-slash, no leading slash). See `MediaPath`'s struct-level + /// doc for the invariant shift (NFC normalization moved to the + /// filesystem layer post-L5). pub relative_path: MediaPath, /// File size in bytes at walk time. pub size: FileSize, @@ -336,11 +346,16 @@ mod tests { } #[test] - fn media_path_nfc_equivalence_fixed() { - // "café" — precomposed (NFC) vs decomposed (NFD). - let precomposed = "caf\u{00E9}"; - let decomposed = "cafe\u{0301}"; - assert_eq!(MediaPath::new(precomposed), MediaPath::new(decomposed)); + fn media_path_no_longer_nfc_collapses() { + // Post-L5: MediaPath::new is lexical-only. NFC and NFD spellings + // of "café" produce byte-distinct MediaPaths. Equivalence across + // Unicode forms is a filesystem-layer concern (fs::canonicalize), + // not a MediaPath::new concern. + let precomposed = "caf\u{00E9}"; // NFC, 5 bytes + let decomposed = "cafe\u{0301}"; // NFD, 6 bytes + assert_ne!(MediaPath::new(precomposed), MediaPath::new(decomposed)); + // But identical spellings still collapse. + assert_eq!(MediaPath::new(precomposed), MediaPath::new(precomposed)); } #[test] diff --git a/crates/core/tests/props_path_nfc_equivalence.rs b/crates/core/tests/props_path_nfc_equivalence.rs index edcdbfe..d6942b5 100644 --- a/crates/core/tests/props_path_nfc_equivalence.rs +++ b/crates/core/tests/props_path_nfc_equivalence.rs @@ -1,18 +1,65 @@ -//! Property: `MediaPath::new` collapses canonically-equivalent -//! inputs. Applying NFD decomposition before construction must -//! yield the same `MediaPath`. +//! macOS-only test: `fs::canonicalize` returns the stored dirent name +//! for any NFC/NFD lookup spelling of the same file. This replaces +//! the pre-L5 proptest that asserted +//! `MediaPath::new(NFC) == MediaPath::new(NFD)` — see +//! `crates/core/src/types.rs` `MediaPath` doc for the invariant shift. +//! +//! # Mechanism (reviewer-verified) +//! +//! Both APFS and HFS+ are normalization-insensitive at the dirent- +//! lookup layer: +//! - HFS+ stores NFD; any NFC or NFD lookup resolves to the NFD entry. +//! - APFS stores caller-form-preserving; lookup uses a hash of the +//! canonically-normalized filename (Apple APFS FAQ). Any NFC or NFD +//! lookup of a given file resolves to the single stored dirent. +//! +//! `realpath(3)` — which `std::fs::canonicalize` calls — returns the +//! stored dirent name (whichever form the file was written under). So +//! `canonicalize(NFC_lookup)` and `canonicalize(NFD_lookup)` return +//! the SAME string (the stored one) on both filesystems. +//! +//! On Linux ext4: byte-exact, no normalization; `canonicalize(NFD)` +//! of an NFC-stored file FAILS. We don't attempt the equivalence +//! there. +//! +//! On Windows NTFS: the Win32 API layer applies some normalization +//! but the behavior is hand-wavy across NT versions + drivers. We +//! skip Windows rather than assert a weaker claim that might mislead +//! a debugger. -use perima_core::MediaPath; -use proptest::prelude::*; -use unicode_normalization::UnicodeNormalization; +#[cfg(target_os = "macos")] +#[test] +fn fs_canonicalize_resolves_nfc_and_nfd_lookups_to_same_stored_name() { + use std::fs; + let dir = tempfile::tempdir().expect("tempdir"); + // Write the fixture under an NFC-spelled filename. + let nfc_name = "caf\u{00E9}.txt"; + let nfc_path = dir.path().join(nfc_name); + fs::write(&nfc_path, b"hello").expect("write NFC fixture"); -proptest! { - #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] - #[test] - fn media_path_nfc_equivalence(s in "\\PC{0,200}") { - let nfd: String = s.nfd().collect(); - let from_original = MediaPath::new(&s); - let from_nfd = MediaPath::new(&nfd); - prop_assert_eq!(from_original, from_nfd); - } + // Look up the same file via its NFD spelling. Both APFS + HFS+ + // resolve this to the SAME stored dirent. + let nfd_name = "cafe\u{0301}.txt"; + let nfd_path = dir.path().join(nfd_name); + + let canon_nfc = fs::canonicalize(&nfc_path).expect("canonicalize NFC"); + let canon_nfd = fs::canonicalize(&nfd_path).expect("canonicalize NFD on macOS"); + assert_eq!( + canon_nfc, canon_nfd, + "macOS realpath should return the same stored dirent name for NFC + NFD lookups" + ); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn media_path_byte_identity_off_macos() { + // On Linux + Windows we don't exercise fs::canonicalize NFC/NFD + // equivalence — Linux has none (byte-exact ext4), Windows is + // hand-wavy across NT versions. The weaker invariant tested here: + // MediaPath::new is deterministic byte-identity under identical + // input. NFC-equivalence is no longer MediaPath's job. + use perima_core::MediaPath; + let s = "caf\u{00E9}/file.txt"; + assert_eq!(MediaPath::new(s), MediaPath::new(s)); + assert_eq!(MediaPath::new(s).as_str(), "caf\u{00E9}/file.txt"); } From 53848d6b6cca0741d458411257769305acee5ad8 Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 16:13:32 +0400 Subject: [PATCH 10/20] docs(core): apply reviewer nits to e170c04 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent code-quality reviewers on commit e170c04 flagged five minor doc / Cargo.toml items. All inline-threshold fixes. 1. `crates/core/Cargo.toml`: switch `tempfile = "3"` to `tempfile.workspace = true` for consistency with every other crate's dev-dep style (the workspace root already pins tempfile). 2. `types.rs` `MediaPath` struct doc: replace the misleading `fs::canonicalize` mention with the accurate `dunce::canonicalize` (delegates to std on non-Windows; strips `\\?\` on Windows). Also hedge the "MediaPath is a machine- derived type" line to "rare in production; this type is typically machine-derived from canonicalized walk output" — test-fixture call sites construct from string literals. 3. `types.rs` `DiscoveredFile.relative_path` doc: drop "post-L5" historical-event jargon in favor of an evergreen reference to the proptest file's mechanism doc. 4. `props_path_nfc_equivalence.rs` module doc: soften the APFS mechanism wording to "macOS VFS layer is normalization- insensitive" (the "hash of canonically-normalized filename" claim over-attributes to APFS itself vs. the VFS). 5. `props_path_nfc_equivalence.rs` module doc: explicitly flag that the off-macOS branch is a trivial byte-identity test kept to keep the file non-empty; real coverage lives in the macOS path. No behavior change; all changes are doc comments + Cargo.toml style. --- crates/core/Cargo.toml | 2 +- crates/core/src/types.rs | 23 +++++++++++-------- .../core/tests/props_path_nfc_equivalence.rs | 22 +++++++++++++----- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index d491408..4384a56 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -16,7 +16,7 @@ unicode-normalization.workspace = true [dev-dependencies] proptest.workspace = true blake3.workspace = true -tempfile = "3" +tempfile.workspace = true [lints] workspace = true diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 3f34f5d..fa00072 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -121,14 +121,17 @@ pub struct FileSize(pub u64); /// will compare unequal by byte. /// /// WHY the NFC guarantee was removed (see lib-audit L5): macOS NFD -/// vs NFC equivalence is better handled at the filesystem layer via -/// `fs::canonicalize`, which round-trips the OS's own normalization -/// in a single syscall. Production `MediaPaths` are constructed -/// downstream of a canonicalized walk (see +/// vs NFC equivalence is better handled at the filesystem layer by +/// round-tripping the OS's own normalization via a canonicalize call +/// (in perima, `dunce::canonicalize` which delegates to +/// `std::fs::canonicalize` on non-Windows and strips `\\?\` prefixes +/// on Windows). Production `MediaPaths` are constructed downstream +/// of a canonicalized walk (see /// `crates/cli/src/cmd/scan.rs::canonicalize_for_walk` and peers), /// so they receive platform-consistent bytes. Callers constructing -/// `MediaPaths` from user-typed strings (which is rare; `MediaPath` is -/// a machine-derived type) accept the byte-exact-match contract. +/// `MediaPaths` from user-typed strings (rare in production; this +/// type is typically machine-derived from canonicalized walk output) +/// accept the byte-exact-match contract. #[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] pub struct MediaPath(String); @@ -195,9 +198,11 @@ pub struct DiscoveredFile { /// Absolute path as observed during the walk. pub absolute_path: PathBuf, /// Path relative to the volume root. Lexical normalization only - /// (forward-slash, no leading slash). See `MediaPath`'s struct-level - /// doc for the invariant shift (NFC normalization moved to the - /// filesystem layer post-L5). + /// (forward-slash, no leading slash). NFC normalization is a + /// filesystem-layer concern handled upstream by the canonicalized + /// walk; see `MediaPath`'s struct-level doc and + /// `crates/core/tests/props_path_nfc_equivalence.rs` for the + /// mechanism. pub relative_path: MediaPath, /// File size in bytes at walk time. pub size: FileSize, diff --git a/crates/core/tests/props_path_nfc_equivalence.rs b/crates/core/tests/props_path_nfc_equivalence.rs index d6942b5..6576146 100644 --- a/crates/core/tests/props_path_nfc_equivalence.rs +++ b/crates/core/tests/props_path_nfc_equivalence.rs @@ -6,12 +6,15 @@ //! //! # Mechanism (reviewer-verified) //! -//! Both APFS and HFS+ are normalization-insensitive at the dirent- -//! lookup layer: -//! - HFS+ stores NFD; any NFC or NFD lookup resolves to the NFD entry. -//! - APFS stores caller-form-preserving; lookup uses a hash of the -//! canonically-normalized filename (Apple APFS FAQ). Any NFC or NFD -//! lookup of a given file resolves to the single stored dirent. +//! On default macOS APFS and HFS+ volumes the VFS layer is +//! normalization-insensitive: +//! - HFS+ stores NFD; any NFC or NFD lookup resolves to the stored +//! NFD entry. +//! - APFS preserves whatever bytes the caller wrote at creation; the +//! macOS VFS layer handles NFC/NFD equivalence at lookup time, so +//! both NFC and NFD spellings of a given file resolve to the one +//! stored dirent. (Case-sensitive APFS variants can change this — +//! out of scope for perima's tooling.) //! //! `realpath(3)` — which `std::fs::canonicalize` calls — returns the //! stored dirent name (whichever form the file was written under). So @@ -26,6 +29,13 @@ //! but the behavior is hand-wavy across NT versions + drivers. We //! skip Windows rather than assert a weaker claim that might mislead //! a debugger. +//! +//! Off-macOS this file contains only a trivial byte-identity test +//! (`media_path_byte_identity_off_macos` below) — the real NFC/NFD +//! coverage runs only under macOS CI. The off-macOS branch exists +//! to keep the test file non-empty on Linux + Windows; it asserts +//! properties already covered by `props_path_idempotence.rs` and +//! the inline tests in `crates/core/src/types.rs`. #[cfg(target_os = "macos")] #[test] From dc20a0f134bb0a1da9e95a57db055a83baa9ffeb Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 16:18:11 +0400 Subject: [PATCH 11/20] refactor(cli): drop dunce direct dep; consume perima_fs::platform_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L5 step 3/5 — path consolidation (lib-audit follow-up spec §2 L5). cli's 4 canonicalize call sites now route through `perima_fs::platform_path::canonicalize` — the `pub` helper introduced in 044e156. dunce stays as a direct dep only on crates/fs, cfg-gated to Windows. Single source of truth for the #[cfg(windows)] dunce/std fallback; no per-crate copy maintained in cli. Call sites swapped: - cmd/scan.rs (canonicalize_for_walk) - cmd/watch.rs (canonicalize) - cmd/metadata.rs - cmd/tag.rs Also fix pre-existing clippy::ptr_arg in tag.rs (&PathBuf → &Path on three private helpers) surfaced by -D warnings. --- crates/cli/Cargo.toml | 1 - crates/cli/src/cmd/metadata.rs | 3 ++- crates/cli/src/cmd/scan.rs | 5 +++-- crates/cli/src/cmd/tag.rs | 10 +++++----- crates/cli/src/cmd/watch.rs | 6 +++--- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 030b833..1c314cc 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -29,7 +29,6 @@ directories.workspace = true ctrlc.workspace = true rayon.workspace = true uuid.workspace = true -dunce.workspace = true tokio.workspace = true tokio-util.workspace = true diff --git a/crates/cli/src/cmd/metadata.rs b/crates/cli/src/cmd/metadata.rs index 5c50850..8a1e0d4 100644 --- a/crates/cli/src/cmd/metadata.rs +++ b/crates/cli/src/cmd/metadata.rs @@ -54,7 +54,8 @@ pub(crate) async fn run( args: &MetadataArgs, ) -> Result<(), CoreError> { validate_file(&args.path)?; - let absolute_path = dunce::canonicalize(&args.path).map_err(CoreError::Io)?; + let absolute_path = + perima_fs::platform_path::canonicalize(&args.path).map_err(CoreError::Io)?; // Resolve volume from the containing directory. WHY parent(): volume // detection inspects the mount point; a file path's parent is the diff --git a/crates/cli/src/cmd/scan.rs b/crates/cli/src/cmd/scan.rs index c6005d7..35982ab 100644 --- a/crates/cli/src/cmd/scan.rs +++ b/crates/cli/src/cmd/scan.rs @@ -443,6 +443,7 @@ fn validate_root(root: &Path) -> Result<(), CoreError> { } fn canonicalize_for_walk(root: &Path) -> Result { - // dunce::canonicalize avoids UNC prefixes on Windows. - dunce::canonicalize(root).map_err(CoreError::Io) + // WHY: routes through perima_fs::platform_path::canonicalize — the single + // source of truth for the #[cfg(windows)] dunce / std fallback. + perima_fs::platform_path::canonicalize(root).map_err(CoreError::Io) } diff --git a/crates/cli/src/cmd/tag.rs b/crates/cli/src/cmd/tag.rs index f9a2956..76b7276 100644 --- a/crates/cli/src/cmd/tag.rs +++ b/crates/cli/src/cmd/tag.rs @@ -6,7 +6,7 @@ //! - `tag ls [--json]` — list all active tags with attachment counts use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use perima_core::{BlakeHash, CoreError, DeviceId, FileRepository, TagRepository, normalize_tag}; @@ -80,7 +80,7 @@ where /// WHY separate helper: both `run_add` and `run_rm` need the same /// canonicalization + suffix-match logic. Extracting it avoids /// duplicating the error messages and the `list_file_locations` call. -fn resolve_hash(file_repo: &F, path: &PathBuf) -> Result +fn resolve_hash(file_repo: &F, path: &Path) -> Result where F: FileRepository + ?Sized, { @@ -97,7 +97,7 @@ where ))); } - let absolute = dunce::canonicalize(path).map_err(CoreError::Io)?; + let absolute = perima_fs::platform_path::canonicalize(path).map_err(CoreError::Io)?; let absolute_str = absolute .to_str() .ok_or_else(|| CoreError::InvalidPath(format!("non-UTF8 path: {}", absolute.display())))?; @@ -122,7 +122,7 @@ fn run_add( tag_repo: &T, file_repo: &F, device: DeviceId, - path: &PathBuf, + path: &Path, tags: &[String], ) -> Result<(), CoreError> where @@ -154,7 +154,7 @@ fn run_rm( tag_repo: &T, file_repo: &F, device: DeviceId, - path: &PathBuf, + path: &Path, tag_raw: &str, ) -> Result<(), CoreError> where diff --git a/crates/cli/src/cmd/watch.rs b/crates/cli/src/cmd/watch.rs index b06cc6c..0406de0 100644 --- a/crates/cli/src/cmd/watch.rs +++ b/crates/cli/src/cmd/watch.rs @@ -163,9 +163,9 @@ fn validate_root(root: &Path) -> Result<(), CoreError> { } fn canonicalize(root: &Path) -> Result { - // WHY dunce: avoids UNC prefixes on Windows while behaving identically - // on Unix. - dunce::canonicalize(root).map_err(CoreError::Io) + // WHY: routes through perima_fs::platform_path::canonicalize — the single + // source of truth for the #[cfg(windows)] dunce / std fallback. + perima_fs::platform_path::canonicalize(root).map_err(CoreError::Io) } // --------------------------------------------------------------------------- From 87db513a22341fc1aa26fb3b7c4150a152f920ce Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 16:25:51 +0400 Subject: [PATCH 12/20] refactor(desktop): drop dunce direct dep; consume perima_fs::platform_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L5 step 4/5 — path consolidation (lib-audit follow-up spec §2 L5). Mirrors commit dc20a0f (cli). desktop's canonicalize call sites in commands.rs now route through `perima_fs::platform_path::canonicalize` — the `pub` helper introduced in 044e156. dunce stays as a direct dep only on crates/fs, cfg-gated to Windows. Single source of truth for the #[cfg(windows)] dunce / std fallback; no per-crate copy maintained in desktop. Call sites swapped: 2 in commands.rs. --- crates/desktop/Cargo.toml | 1 - crates/desktop/src/commands.rs | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml index b951134..4ebd785 100644 --- a/crates/desktop/Cargo.toml +++ b/crates/desktop/Cargo.toml @@ -32,7 +32,6 @@ tracing.workspace = true uuid.workspace = true chrono.workspace = true rayon.workspace = true -dunce.workspace = true directories.workspace = true tokio.workspace = true tokio-util.workspace = true diff --git a/crates/desktop/src/commands.rs b/crates/desktop/src/commands.rs index a75538a..b05ab2a 100644 --- a/crates/desktop/src/commands.rs +++ b/crates/desktop/src/commands.rs @@ -326,7 +326,10 @@ pub async fn run_scan_inner_with_metadata( metadata_repo: Option>, ) -> Result { validate_root(root)?; - let canonical_root = dunce::canonicalize(root).map_err(perima_core::CoreError::Io)?; + // WHY: routes through perima_fs::platform_path::canonicalize — the single + // source of truth for the #[cfg(windows)] dunce / std fallback. + let canonical_root = + perima_fs::platform_path::canonicalize(root).map_err(perima_core::CoreError::Io)?; let scanner = WalkdirScanner::new(); let hasher = Blake3Service::new(); @@ -535,7 +538,8 @@ pub async fn start_watch( ) -> Result<(), String> { let root = PathBuf::from(&path); validate_root(&root).map_err(|e| e.to_string())?; - let canonical_root = dunce::canonicalize(&root).map_err(|e| format!("canonicalize: {e}"))?; + let canonical_root = + perima_fs::platform_path::canonicalize(&root).map_err(|e| format!("canonicalize: {e}"))?; let detected = perima_fs::detect_volume(&canonical_root).map_err(|e| e.to_string())?; let db_path = state.data_dir.join("perima.db"); From 2dd810a775cfd27ec6b8ebf024639a96595caeae Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 16:33:11 +0400 Subject: [PATCH 13/20] docs(workspace,core): link tag-NFC follow-up issues in WHY comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L5 step 5/5 — path consolidation (lib-audit follow-up spec §2 L5). Closes the L5 paperwork: - `Cargo.toml`'s workspace `unicode-normalization` entry now has a WHY comment pointing to GH #112 for the long-term tag-NFC strategy question. - `crates/core/src/tag.rs` gains a WHY comment at the unicode-normalization import pointing to the same issue, plus a cross-reference to the macOS NFD migration issue for completeness. AGENTS.md's L5 pin block was authored but NOT committed — the file matches the `**/*.md` gitignore rule without an exception, per an earlier user directive to keep AGENTS.md working-tree-only. Future sessions on this machine still see it; cloud / fresh-clone agents won't (accepted limitation from Batch A). Two GH issues filed: - #112 Re-evaluate tag NFC normalization strategy (post-v1) - #113 macOS NFD-vs-NFC file_locations + FTS5 duplication on re-scan (phase-9 hardening) --- Cargo.toml | 2 +- crates/core/src/tag.rs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ac53c93..a4a62b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,7 +67,7 @@ directories = "6" # unicode-normalization: KEPT only for tag NFC normalization in crates/core/src/tag.rs. # DO NOT use in path code — path equivalence is handled via fs::canonicalize round-trip # (see crates/core/tests/props_path_nfc_equivalence.rs). Strategy review tracked in -# GH issue #. +# GH issue #112. unicode-normalization = "0.1" dunce = "1" file-id = "0.2" diff --git a/crates/core/src/tag.rs b/crates/core/src/tag.rs index 5aec789..d712f3e 100644 --- a/crates/core/src/tag.rs +++ b/crates/core/src/tag.rs @@ -1,6 +1,11 @@ //! Tag value type + name normalization. use serde::{Deserialize, Serialize}; +// WHY: `unicode-normalization` is KEPT in the workspace solely for +// this tag-name NFC normalization. Path code (MediaPath) no longer +// uses it post-L5 (see GH issue #113). See GH issue +// #112 for the long-term tag normalization question (is NFC +// load-bearing, or handled by SQLite collation?). use unicode_normalization::UnicodeNormalization; use uuid::Uuid; From 57c3752c63b5c188c7d599d1ec43c4f817c6e97f Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 17:32:34 +0400 Subject: [PATCH 14/20] =?UTF-8?q?chore(desktop,ci):=20bump=20Vite=206=20?= =?UTF-8?q?=E2=86=92=208=20and=20@vitejs/plugin-react=204=20=E2=86=92=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L2 step 2/4 — frontend modernization bundle (lib-audit follow-up spec §2 L2). PR-2 closeout. - vite: ^6 → ^8 (resolved 8.0.9). Rolldown-default; eliminates the esbuild dev / Rollup prod bundler split. - @vitejs/plugin-react: ^4 → ^6 (resolved 6.0.1). Retains Babel pass for React Compiler (wired in PR-3); React Refresh switched to Oxc. - Node engines range bumped to Vite 8 minimum (>=20.19 <21 || >=22.12). - vitest pin tightened from ^3 to ^3.2 (resolved 3.2.4, meets Vite 8 floor per audit §Q37). - CI Node pinned to 22.12 via a new actions/setup-node@v4 step positioned before oven-sh/setup-bun@v2 (runner previously used an unpinned default not guaranteed to meet Vite 8's floor). Bundle size delta: 227,232 bytes (was 231,269 bytes, -1.7%, within ±5%). @tailwindcss/vite peer range verified compatible with Vite 8 (^5.2.0 || ^6 || ^7 || ^8 — pre-flight gate). 68/68 tests, lint, build all green post-bump. --- .github/workflows/ci.yml | 4 ++ apps/desktop/bun.lock | 80 ++++++++++++++++++++++++++++----------- apps/desktop/package.json | 9 +++-- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a663a4a..3a43c15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,10 @@ jobs: - uses: taiki-e/install-action@cargo-deny - uses: taiki-e/install-action@typos + - uses: actions/setup-node@v4 + with: + node-version: '22.12' + - uses: oven-sh/setup-bun@v2 with: bun-version: latest diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index 33c7138..e7b80a5 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -21,7 +21,7 @@ "@types/react-dom": "^19", "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", - "@vitejs/plugin-react": "^4", + "@vitejs/plugin-react": "^6", "eslint": "^9", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", @@ -30,8 +30,8 @@ "jsdom": "^26", "tailwindcss": "^4", "typescript": "^5", - "vite": "^6", - "vitest": "^3", + "vite": "^8", + "vitest": "^3.2", }, }, }, @@ -56,8 +56,6 @@ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -68,10 +66,6 @@ "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], @@ -90,6 +84,12 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -184,13 +184,47 @@ "@microsoft/tsdoc-config": ["@microsoft/tsdoc-config@0.17.1", "", { "dependencies": { "@microsoft/tsdoc": "0.15.1", "ajv": "~8.12.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], @@ -306,15 +340,9 @@ "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], - "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], - - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -348,7 +376,7 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], @@ -834,8 +862,6 @@ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], @@ -850,6 +876,8 @@ "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="], + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], @@ -950,6 +978,8 @@ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -968,7 +998,7 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + "vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], @@ -1058,8 +1088,14 @@ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "vite-node/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + + "vitest/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], + "@microsoft/tsdoc-config/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bacdd5b..2f4b752 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,6 +11,9 @@ "preview": "vite preview", "tauri": "tauri" }, + "engines": { + "node": ">=20.19 <21 || >=22.12" + }, "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", @@ -28,7 +31,7 @@ "@types/react-dom": "^19", "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", - "@vitejs/plugin-react": "^4", + "@vitejs/plugin-react": "^6", "eslint": "^9", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", @@ -37,7 +40,7 @@ "jsdom": "^26", "tailwindcss": "^4", "typescript": "^5", - "vite": "^6", - "vitest": "^3" + "vite": "^8", + "vitest": "^3.2" } } From bcb06dcfd4a57059c00044deff119faa003a55f0 Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 17:41:22 +0400 Subject: [PATCH 15/20] chore(desktop): pin packageManager to bun@1.3.11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec deviation fix for L2 PR-2. Spec §2 L2 line 154 mandates "Bump `packageManager: "bun@1.3.11"` pin (audit §Q36)". The field was absent from apps/desktop/package.json, so the prior commit (57c3752) silently dropped the requirement as YAGNI. This adds the field so Corepack / package-manager detection resolves to the audit-pinned version. `bun install` is a no-op (0 changes); lint still clean. --- apps/desktop/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2f4b752..eb3ddd8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.6.3", "type": "module", + "packageManager": "bun@1.3.11", "scripts": { "dev": "vite", "build": "tsc -b && vite build", From ceb508aa6db7b207973569c2813d0f5ae92e1be3 Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 17:51:50 +0400 Subject: [PATCH 16/20] feat(desktop): adopt React Compiler 1.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L2 step 3/4 — frontend modernization bundle (lib-audit follow-up spec §2 L2). PR-3 closeout. - babel-plugin-react-compiler added at EXACT pin =1.0.0 (not ^). - @rolldown/plugin-babel@^0.2.3 added as peer; @babel/core@^7 and @babel/preset-typescript@^7 added as dev deps (needed by the canary test described below). - vite.config.ts: plugin-react v6 removed Babel from the default pipeline (React Refresh now runs through Oxc). The Compiler re- adds Babel as a SEPARATE plugin via `@rolldown/plugin-babel` configured with `reactCompilerPreset({ target: "19" })` — the canonical v6 wiring per https://github.com/vitejs/vite-plugin-react/blob/plugin-react@6.0.1/packages/plugin-react/README.md Compiler runs in default (infer) mode — auto-memoizes every component that satisfies rules-of-react. `target: "19"` is pinned explicitly so a future minor can't silently flip the runtime emit path. - Spec-mandated canary (src/__tests__/compiler-canary.test.ts) runs babel-plugin-react-compiler in annotation mode over a known-good `"use memo"` snippet (directive inside function body, not at program level — Compiler treats it as a function-level directive) and asserts the transformed output imports from `react/compiler-runtime` (React 19's built-in runtime path). This is stable against minifier renames and decoupled from Vite build state — proves the plugin chain itself works. Bundle size delta: 227,232 → 234,711 bytes raw (+7,479 bytes; well under the 20 KB flag threshold). The delta is the Compiler's react/compiler-runtime cache machinery. ## What this enables Manual useCallback / useMemo are now an anti-pattern for new components; the Compiler handles memoization. Existing manual memos (currently 1: App.tsx useCallback) stay for now — a follow-up GH issue will audit and remove them once Compiler adoption is proven in practice. ## What this doesn't change No source-code changes to src/**. The Compiler is transparent at source level; only the transform pipeline added the extra Babel plugin. --- apps/desktop/bun.lock | 32 +++++++++++ apps/desktop/package.json | 4 ++ .../src/__tests__/compiler-canary.test.ts | 53 +++++++++++++++++++ apps/desktop/vite.config.ts | 22 ++++++-- 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/__tests__/compiler-canary.test.ts diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index e7b80a5..3830dbf 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -12,7 +12,10 @@ "react-dom": "^19", }, "devDependencies": { + "@babel/core": "^7", + "@babel/preset-typescript": "^7", "@evilmartians/lefthook": "^2.1.6", + "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "^4", "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6", @@ -22,6 +25,7 @@ "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", "@vitejs/plugin-react": "^6", + "babel-plugin-react-compiler": "=1.0.0", "eslint": "^9", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", @@ -48,14 +52,28 @@ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -66,6 +84,16 @@ "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], @@ -224,6 +252,8 @@ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="], + "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.3", "", { "dependencies": { "picomatch": "^4.0.4" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], @@ -430,6 +460,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "babel-plugin-react-compiler": ["babel-plugin-react-compiler@1.0.0", "", { "dependencies": { "@babel/types": "^7.26.0" } }, "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index eb3ddd8..414d6da 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -32,7 +32,11 @@ "@types/react-dom": "^19", "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", + "@babel/core": "^7", + "@babel/preset-typescript": "^7", + "@rolldown/plugin-babel": "^0.2.3", "@vitejs/plugin-react": "^6", + "babel-plugin-react-compiler": "=1.0.0", "eslint": "^9", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/apps/desktop/src/__tests__/compiler-canary.test.ts b/apps/desktop/src/__tests__/compiler-canary.test.ts new file mode 100644 index 0000000..a4c6b44 --- /dev/null +++ b/apps/desktop/src/__tests__/compiler-canary.test.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ +// WHY: @babel/core ships no TypeScript declarations and @types/babel__core is not +// in devDependencies. This test is a pure compile-pipeline canary (not shipped +// code) so suppressing unsafe-* here is the right tradeoff vs adding a +// separate @types package for a single test file. +import { describe, expect, it } from "vitest"; +import * as babel from "@babel/core"; +// WHY: @babel/plugin-syntax-jsx is needed so preset-typescript (which handles +// TypeScript stripping) doesn't reject JSX tokens — the Compiler itself requires +// real JSX in the source to recognise a React component and apply memoization. +import babelPluginSyntaxJsx from "@babel/plugin-syntax-jsx"; + +// WHY: Spec-mandated Compiler-activation canary. Runs babel-plugin-react-compiler +// directly in `annotation` mode on a known-good snippet, bypasses the Vite build +// pipeline entirely, and asserts the transformed source imports from +// `react/compiler-runtime` (React 19's built-in runtime path). +// +// This is stable against minifier renames (which is why the spec rejects +// bundle-grep as the primary verification). +// +// WHY "use memo" inside the function body (not at file level): the React Compiler +// treats "use memo" as a function-level directive (like "use strict") — placing +// it at the program level is parsed but silently ignored by the Compiler's +// annotation-mode opt-in logic. File-level opt-in requires `compilationMode: "all"`. +const CANARY_SOURCE = ` + import { useState } from "react"; + export function Canary() { + "use memo"; + const [n, setN] = useState(0); + return
{n}
; + } +`; + +describe("react-compiler transform", () => { + it("emits react/compiler-runtime import for a 'use memo' annotated component", async () => { + const result = await babel.transformAsync(CANARY_SOURCE, { + filename: "canary.tsx", + presets: [ + ["@babel/preset-typescript", { allExtensions: true, isTSX: true }], + ], + plugins: [ + babelPluginSyntaxJsx, + [ + "babel-plugin-react-compiler", + { target: "19", compilationMode: "annotation" }, + ], + ], + babelrc: false, + configFile: false, + }); + expect(result?.code).toMatch(/from ["']react\/compiler-runtime["']/); + }); +}); diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index e463676..7747a18 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -1,10 +1,26 @@ import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; +import react, { reactCompilerPreset } from "@vitejs/plugin-react"; +import babel from "@rolldown/plugin-babel"; import tailwindcss from "@tailwindcss/vite"; // WHY: Tailwind 4 uses the Vite plugin (not PostCSS) — no tailwind.config.js needed. +// WHY: React Compiler 1.0 runs via a separate `@rolldown/plugin-babel` pass +// configured with `reactCompilerPreset()` exported by @vitejs/plugin-react. +// This is the canonical wiring for plugin-react v6 (see the v6 README). +// Compiler auto-memoizes components; DO NOT add manual useMemo/useCallback +// for new code. See AGENTS.md "Frontend / React Compiler" pin. +// WHY: target: '19' is pinned explicitly even though '19' is the current +// default — relying on the implicit default is a silent-failure hazard. +// A future react-compiler minor could flip the default, switching our +// runtime emit from `react/compiler-runtime` (React 19 built-in) to +// `react-compiler-runtime` (17/18 polyfill) and defeating the Step 4 +// grep canary. Explicit pin costs one line and eliminates that class. export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [ + react(), + babel({ presets: [reactCompilerPreset({ target: "19" })] }), + tailwindcss(), + ], server: { port: 5173, // WHY: Fail fast if port is taken; Tauri dev expects exactly 5173. @@ -18,8 +34,6 @@ export default defineConfig({ environment: "jsdom", setupFiles: ["src/__tests__/setup.ts"], globals: true, - // WHY: tsc composite mode emits to dist-types; exclude it so vitest - // doesn't double-run the compiled JS alongside the original TSX. exclude: ["dist-types/**", "node_modules/**"], include: ["src/**/*.{test,spec}.{ts,tsx}"], }, From e4f24ef463189a86cd1380dcfaed2e7e2c05f8f2 Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 18:02:38 +0400 Subject: [PATCH 17/20] fix(desktop): repair TS build break + phantom dep on Compiler canary L2 step 3/4 follow-up. Applies reviewer nits (CHANGES_REQUESTED from Opus code-quality reviewer #2) to commit ceb508a. - Add @types/babel__core: fixes TS7016 error that broke `tsc -b` (and thus `bun run build`) in ceb508a. Canary test was green under vitest (no typecheck) but production build failed. - Add @babel/plugin-syntax-jsx as explicit devDep: it was being resolved transitively through @babel/preset-typescript, a phantom-dep anti-pattern that would break on a future hoist change. Now declared directly. - Add src/vendor.d.ts with ambient module declaration for @babel/plugin-syntax-jsx: no @types/babel__plugin-syntax-jsx exists on DefinitelyTyped; ambient decl typed as `object` to match @types/babel__core's PluginTarget union. - Remove file-scoped `eslint-disable no-unsafe-*` block from the canary: no longer needed with @types/babel__core present. Also applies code-quality nits: - Reinstate the `// WHY:` comment on vitest's `dist-types` exclude in vite.config.ts (dropped during ceb508a's config rewrite). - Add a `// WHY: keep in sync with vite.config.ts` comment on the canary's `target: "19"` to prevent silent drift if the prod target is bumped without updating the canary. - Add a `// WHY:` comment on reactCompilerPreset's implicit `panicThreshold: "none"` default. Verified: `bun run build` (exit 0), `bun test` (58/58 src pass, canary included), `bun run lint` (clean). --- apps/desktop/bun.lock | 10 ++++++++++ apps/desktop/package.json | 2 ++ apps/desktop/src/__tests__/compiler-canary.test.ts | 12 ++++++------ apps/desktop/src/vendor.d.ts | 10 ++++++++++ apps/desktop/vite.config.ts | 5 +++++ 5 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/vendor.d.ts diff --git a/apps/desktop/bun.lock b/apps/desktop/bun.lock index 3830dbf..9d7860b 100644 --- a/apps/desktop/bun.lock +++ b/apps/desktop/bun.lock @@ -13,6 +13,7 @@ }, "devDependencies": { "@babel/core": "^7", + "@babel/plugin-syntax-jsx": "^7", "@babel/preset-typescript": "^7", "@evilmartians/lefthook": "^2.1.6", "@rolldown/plugin-babel": "^0.2.3", @@ -20,6 +21,7 @@ "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6", "@testing-library/react": "^16", + "@types/babel__core": "^7", "@types/react": "^19", "@types/react-dom": "^19", "@typescript-eslint/eslint-plugin": "^8.58.2", @@ -374,6 +376,14 @@ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 414d6da..039bee4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -28,11 +28,13 @@ "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6", "@testing-library/react": "^16", + "@types/babel__core": "^7", "@types/react": "^19", "@types/react-dom": "^19", "@typescript-eslint/eslint-plugin": "^8.58.2", "@typescript-eslint/parser": "^8.58.2", "@babel/core": "^7", + "@babel/plugin-syntax-jsx": "^7", "@babel/preset-typescript": "^7", "@rolldown/plugin-babel": "^0.2.3", "@vitejs/plugin-react": "^6", diff --git a/apps/desktop/src/__tests__/compiler-canary.test.ts b/apps/desktop/src/__tests__/compiler-canary.test.ts index a4c6b44..f5b2a32 100644 --- a/apps/desktop/src/__tests__/compiler-canary.test.ts +++ b/apps/desktop/src/__tests__/compiler-canary.test.ts @@ -1,8 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ -// WHY: @babel/core ships no TypeScript declarations and @types/babel__core is not -// in devDependencies. This test is a pure compile-pipeline canary (not shipped -// code) so suppressing unsafe-* here is the right tradeoff vs adding a -// separate @types package for a single test file. import { describe, expect, it } from "vitest"; import * as babel from "@babel/core"; // WHY: @babel/plugin-syntax-jsx is needed so preset-typescript (which handles @@ -42,7 +37,12 @@ describe("react-compiler transform", () => { babelPluginSyntaxJsx, [ "babel-plugin-react-compiler", - { target: "19", compilationMode: "annotation" }, + { + // WHY: keep in sync with vite.config.ts's reactCompilerPreset target option; + // a silent drift would assert the wrong runtime import path. + target: "19", + compilationMode: "annotation", + }, ], ], babelrc: false, diff --git a/apps/desktop/src/vendor.d.ts b/apps/desktop/src/vendor.d.ts new file mode 100644 index 0000000..84d61de --- /dev/null +++ b/apps/desktop/src/vendor.d.ts @@ -0,0 +1,10 @@ +// WHY: @babel/plugin-syntax-jsx ships no TypeScript declarations and no +// @types/babel__plugin-syntax-jsx exists on DefinitelyTyped. Declaring the +// module here satisfies tsc without pinning to `any` across the whole project. +// The plugin is used only in compiler-canary.test.ts as a Babel plugin handle; +// typed as `object` to match @types/babel__core's PluginTarget union, which +// is what babel.transformAsync's `plugins` array ultimately accepts. +declare module "@babel/plugin-syntax-jsx" { + const plugin: object; + export default plugin; +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 7747a18..9f3ccc2 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -18,6 +18,9 @@ import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [ react(), + // WHY: panicThreshold defaults to `none` — Compiler silently skips bailout + // components rather than failing the build; acceptable for a greenfield + // frontend where the component surface is fully under our control. babel({ presets: [reactCompilerPreset({ target: "19" })] }), tailwindcss(), ], @@ -34,6 +37,8 @@ export default defineConfig({ environment: "jsdom", setupFiles: ["src/__tests__/setup.ts"], globals: true, + // WHY: tsc composite mode emits to dist-types; exclude it so vitest doesn't + // double-run the compiled JS alongside the original TSX. exclude: ["dist-types/**", "node_modules/**"], include: ["src/**/*.{test,spec}.{ts,tsx}"], }, From 59eb35a9057dad8a751b906544b7b02d5e1e3ac0 Mon Sep 17 00:00:00 2001 From: utof Date: Mon, 20 Apr 2026 18:09:27 +0400 Subject: [PATCH 18/20] chore(desktop): link react-hooks v7 audit follow-up issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L2 step 4/4 — frontend modernization bundle (lib-audit follow-up spec §2 L2). PR-4 closeout. eslint.config.js previously carried an unfulfilled "Follow-up: file issue for each warn below" comment. Filed GH issue #116 as an umbrella for the 13 warn-downgraded v7 rules; link from the comment. Nothing else in L2 required code changes: Tailwind v4 + ESLint 9 + react-hooks v7.1.1 + `recommended-latest` preset were already in place prior to this batch. Vite 8 bump landed in commit 57c3752; React Compiler 1.0 landed in commit ceb508a. AGENTS.md's 5 L2 pin entries were authored in the working tree but not committed (file is gitignored by `**/*.md` rule; per user directive AGENTS.md stays working-tree-only). Cloud / fresh-clone agents won't see the pins (accepted limitation from Batch A). `eslint-plugin-react-compiler` is not installed (verified empty in package.json + bun.lock) — superseded by react-hooks v7.1 which ships Compiler-aware rules. Current fire count for the 13 warn-downgraded rules: 0 (P2/cosmetic). --- apps/desktop/eslint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/eslint.config.js b/apps/desktop/eslint.config.js index a58843d..c8a6a59 100644 --- a/apps/desktop/eslint.config.js +++ b/apps/desktop/eslint.config.js @@ -103,7 +103,7 @@ export default [ // WHY: react-hooks v7 adds many new experimental rules. The classic two // (rules-of-hooks, exhaustive-deps) are the critical ones; downgrade the // rest to warn until we've audited the codebase against v7's semantics. - // Follow-up: https://github.com/utof/perima (file issue for each warn below) + // Follow-up: see GH issue #116 for the audit plan. "react-hooks/static-components": "warn", "react-hooks/use-memo": "warn", "react-hooks/void-use-memo": "warn", From 3bcd0bf92b64af39e2366507b15e713764d73882 Mon Sep 17 00:00:00 2001 From: utof Date: Tue, 21 Apr 2026 16:52:26 +0400 Subject: [PATCH 19/20] chore(workspace): tighten Tauri / tauri-build / plugin-dialog to tilde-minor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L6 — lib-audit follow-up spec §2 L6 line 289 calls for tightening plugin pins. Previously the three workspace-level pins were loose (`"2"` = `^2.0.0` = any 2.x), which allowed `cargo update` to bump Tauri 2.10.3 → 2.11 or plugin-dialog 2.7.0 → 2.8.0 between sessions with no review. New pins (tilde-minor = `>=major.minor, Date: Tue, 21 Apr 2026 17:14:34 +0400 Subject: [PATCH 20/20] fix(core): allow clippy::similar_names on macOS-only NFC/NFD test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI macOS runner (Rust 1.95 clippy) flagged `nfc_name` / `nfd_name` and `canon_nfc` / `canon_nfd` bindings in the `#[cfg(target_os = "macos")]` test body as too-similar. The names are domain terms of art — Unicode NFC (precomposed) and NFD (decomposed) forms — so renaming to `precomposed` / `decomposed` loses the terminology. Scoped allow on the test function only; Ubuntu CI does not compile this branch and was clean pre-fix. --- crates/core/tests/props_path_nfc_equivalence.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/core/tests/props_path_nfc_equivalence.rs b/crates/core/tests/props_path_nfc_equivalence.rs index 6576146..0b482fe 100644 --- a/crates/core/tests/props_path_nfc_equivalence.rs +++ b/crates/core/tests/props_path_nfc_equivalence.rs @@ -39,6 +39,7 @@ #[cfg(target_os = "macos")] #[test] +#[allow(clippy::similar_names)] // NFC/NFD are domain terms of art — intentional fn fs_canonicalize_resolves_nfc_and_nfd_lookups_to_same_stored_name() { use std::fs; let dir = tempfile::tempdir().expect("tempdir");