smix-sdk 0.1.0

smix-sdk — user-facing public surface for the smix Rust library. App + ergonomic selector helpers + matchers. Wraps SimctlDriver + SimctlClient + HttpRunnerClient. v3.1 c11.
Documentation
//! v5.0 C1 — capability-cover allowlist verifier.
//!
//! Greps `smix-sdk/src/lib.rs` for `pub (async) fn` declarations and
//! cross-checks each against three hand-curated lists:
//!
//! - `C1_COVER`       — exercised by the selftest scenario *this* cycle
//! - `C2_DEFERRED`    — explicitly deferred to the next checkpoint
//! - `PERMANENT_SKIP` — builder / getter / pure constructor functions
//!   that the scenario covers transitively, not as a standalone
//!   capability segment
//!
//! Any `pub fn` not in one of these three lists is *unclassified* — a
//! capability the SDK exposes that the selftest does NOT account for.
//! That is the v5.0 retrogression-gate's whole point: the moment a new
//! `pub fn` lands without being placed in one of the three buckets,
//! verifier reports it and the gate fails. Likewise the same name
//! appearing in two lists is a curation bug — `Duplicate`.
//!
//! Pure function over a text input — no IO, no `std::env` — so the
//! selftest example can `include_str!("../../src/lib.rs")` at compile
//! time and the verifier behaves identically on every machine.

use std::collections::BTreeMap;

/// C1 cycle cover — every `pub fn` here is exercised by the selftest
/// scenario at least once on real-sim. 37 capabilities.
///
/// Layout (kept stable so cycle-close diffs are readable):
/// - 8 sense
/// - 17 act
/// - 1 system (popup action)
/// - 4 assertion
/// - 6 selector factory
/// - 1 pure planner
pub const C1_COVER: &[&str] = &[
    // sense (8) — `focused` live-wired since v5.1 c1 (KVC walk on
    // _hasKeyboardFocus ivar; public dictionaryRepresentation filters
    // the key out, KVC bypass exposes the live first responder).
    "tree",
    "find",
    "find_one",
    "find_all",
    "describe",
    "focused",
    "screenshot",
    "system_popups",
    // act — lifecycle + interaction (17)
    "launch",
    "launch_fresh",
    "terminate",
    "install",
    "uninstall",
    "open_url",
    "send_push",
    "mark_fixture_action",
    "foreground",
    "tap",
    "tap_with_mode",
    "tap_xcui",
    "tap_at_coord",
    "fill",
    "clear",
    "press_key",
    "scroll",
    "swipe_once",
    "hide_keyboard",
    "go_back",
    // system popup act (1)
    "system_popup_action",
    // assertion (4)
    "wait_for",
    "assert_visible",
    "assert_enabled",
    "assert_text",
    // selector factory (6) — `focused` is listed under sense, not here
    "text",
    "text_regex",
    "id",
    "label",
    "role",
    "role_named",
    // pure planner (1)
    "plan_launch_fresh_calls",
    // v5.22 followup cover+1 closure — v5.19/v5.20/v5.21 SDK methods
    // moved from C2_DEFERRED. SDK self-path scenario segs land in
    // seg_v2_anchor_basic / seg_v2_ocr_basic / seg_v2_webview_basic
    // (3 segs cover these 4 SDK methods).
    "find_norm_coord",  // L6 anchored centroid resolve (v5.20 c1)
    "find_by_text_ocr", // L5 OCR find (v5.19 c1)
    "tap_by_text_ocr",  // L5 OCR find + tap_at_norm_coord IOHID (v5.19 c1)
    "webview_eval",     // WKWebView JS eval via fixture bridge (v5.21 c1b)
    // v5.22 followup-2 cover batch closure — v5.2 c7 maestro_parity
    // deferred items. 14 exercised via existing maestro_parity_basic +
    // various v2_* segs; 4 new SDK self-path segs added:
    // seg_double_tap_basic / seg_long_press_basic /
    // seg_copy_text_from_basic / seg_set_permissions_batch_basic.
    "swipe_at_coord",          // §9 #3 escape hatch (maestro_parity)
    "scroll_screen",           // viewport-level scroll (maestro_parity)
    "assert_not_visible",      // dual of assert_visible (maestro_parity)
    "wait_for_not_visible",    // dual of wait_for (modal sheet/alert segs)
    "set_permission",          // single-permission (v2_permission_basic)
    "launch_app_with_options", // typed launch (v2_argv_basic / others)
    "set_clipboard",           // clipboard set (maestro_parity)
    "get_clipboard",           // clipboard get (maestro_parity)
    "set_location",            // sim location (maestro_parity + v2_location)
    "travel",                  // location route (maestro_parity)
    "set_orientation",         // device rotation (v2_orientation_basic)
    "start_recording",         // simctl recordVideo (v2_recording_basic)
    "stop_recording",          // SIGINT recordVideo (v2_recording_basic)
    "assert_screenshot",       // dhash visual regression (maestro_parity)
    "double_tap",              // XCUI gesture (seg_double_tap_basic)
    "long_press",              // XCUI gesture (seg_long_press_basic)
    "copy_text_from",          // clipboard copy (seg_copy_text_from_basic)
    "set_permissions",         // bulk permissions (seg_set_permissions_batch_basic)
];

/// C2 cycle cover (deferred from C1) — **empty after v5.1 c1**.
///
/// `focused` was deferred in v5.0 because the swift-bridge wire serialized
/// `hasFocus` as a hardcoded `false` placeholder. v5.1 c1 (2026-06-14)
/// rewired it through a Foundation KVC walk on `XCElementSnapshot._hasKeyboardFocus`
/// (Apple's public `dictionaryRepresentation` filters the key out across
/// iOS 15+; the ivar is still populated). Real-sim verified — focused
/// `Selector::Focused` matches input-email after `app.fill(&id("input-email"))`.
/// Details: `docs/plan-history/v5.1-c1-vs-maestro-report.md`.
pub const C2_DEFERRED: &[&str] = &[
    // v5.22 followup-2 cover batch closure — v5.2 c7 deferred items
    // closed. All exercised methods moved to C1_COVER; under-exercised
    // methods retained here only where genuine setup blockers remain.
    //
    // paste_text — needs a keyboard-focused input element with known
    // clipboard contents at runtime; the v2_keyboard_basic seg uses
    // press_key/clear/fill but not paste. Adapter corpus exercises
    // pasteText yaml command via mock-only harness. Schedule a real
    // seg when the V2 keyboard surface adds a paste-target probe.
    "paste_text",
    // add_media — needs a PNG/MP4 asset checked into the fixture
    // bundle to seed `simctl addmedia`. Adapter corpus covers the
    // addMedia yaml command via mock. Schedule when V2 has a media
    // surface (photo picker that reads from Photos.framework).
    "add_media",
];

/// Permanent-skip — App builder methods and getters. Exercised
/// transitively whenever the example wires the App (`App::new` /
/// `App::connect_to_runner` / `.with_udid(...)`), so no per-capability
/// segment is needed; never count toward coverage.
///
/// Note: selector-factory functions (`text` / `id` / `role` / etc.) now
/// live in `C1_COVER`, not here — each has a dedicated segment that
/// exercises the factory + the matcher path it produces.
pub const PERMANENT_SKIP: &[&str] = &[
    "new",
    "new_with",
    "connect_to_runner",
    "connect_to_runner_android",
    "with_udid",
    "udid",
    "driver",
    "simctl",
    "device",
    // v5.1 c3 — Capsule 软胶囊 (G angle) SDK helpers,不是 user capability
    // (是 SDK 内部工具,不进 selftest scenario)。
    "start_capsule_recording",
    "stop_capsule_recording_and_reconcile",
    // v5.2 c5 — `MaestroOrientation::to_driver` 是 enum-to-enum 1:1 conversion
    // helper, 非 capability (driver 内部映射), 跟 builder/getter 一类.
    "to_driver",
    // v5.2 c6 — `assert_screenshot_inner` 是 App::assert_screenshot 的纯函数
    // 内核 (无 App dep, host-side smoke 测用), 非用户 capability.
    "assert_screenshot_inner",
];

/// Why a verifier run failed. Always lists *all* offending names so a
/// single error message tells the gate operator everything they need
/// to fix in one round-trip.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VerifyError {
    /// `pub fn` declarations found in `lib.rs` that are not in any of
    /// the three allowlists. The retrogression gate exists to catch
    /// these — the SDK gained a capability but the selftest does not.
    Unclassified(Vec<String>),
    /// A function name appears in two (or three) allowlists. Always a
    /// curation bug — fix the lists, do not silence the verifier.
    Duplicate {
        name: String,
        first_list: &'static str,
        second_list: &'static str,
    },
}

/// Snapshot of one verifier run.
#[derive(Clone, Debug, PartialEq)]
pub struct VerifyReport {
    /// Total `pub fn` declarations grepped from `lib.rs`.
    pub sdk_pub_fn_count: usize,
    /// `pub fn` declarations placed into one of the three allowlists.
    /// Always equal to `sdk_pub_fn_count` when verify returns `Ok`.
    pub classified_count: usize,
    /// Exercised by the selftest scenario *this* cycle.
    pub c1_cover_count: usize,
    /// Deferred to the next checkpoint.
    pub c2_deferred_count: usize,
    /// Permanently skipped (builders / getters).
    pub permanent_skip_count: usize,
    /// `c1_cover / (sdk_pub_fn_count - permanent_skip_count) * 100` — the
    /// headline "coverage %" number printed on every gate run and tracked
    /// across cycles. PERMANENT_SKIP entries (builders, getters, setup
    /// accessors) are not capabilities, so they are excluded from the
    /// denominator; counting them would punish the score for surface that
    /// was never a capability target. v5.0 C2 口径校正.
    pub coverage_pct: f64,
}

impl VerifyReport {
    pub fn from_counts(sdk_count: usize, c1: usize, c2: usize, skip: usize) -> Self {
        let classified = c1 + c2 + skip;
        let coverage_pct = if sdk_count <= skip {
            0.0
        } else {
            (c1 as f64) / ((sdk_count - skip) as f64) * 100.0
        };
        Self {
            sdk_pub_fn_count: sdk_count,
            classified_count: classified,
            c1_cover_count: c1,
            c2_deferred_count: c2,
            permanent_skip_count: skip,
            coverage_pct,
        }
    }
}

/// Walk every line of `lib_rs` looking for `pub fn` / `pub async fn`
/// declarations and return the bare function names. Skips comment
/// lines (`///` / `//`). Idempotent on multiple invocations.
pub fn grep_pub_fn(lib_rs: &str) -> Vec<String> {
    let mut names = Vec::new();
    for raw in lib_rs.lines() {
        let line = raw.trim_start();
        if line.starts_with("//") {
            continue;
        }
        // accept both `pub fn` and `pub async fn`
        let rest = if let Some(r) = line.strip_prefix("pub fn ") {
            r
        } else if let Some(r) = line.strip_prefix("pub async fn ") {
            r
        } else {
            continue;
        };
        // up to the first `(` or `<`
        let end = rest.find(['(', '<']).unwrap_or(rest.len());
        let name = rest[..end].trim();
        if !name.is_empty() {
            names.push(name.to_string());
        }
    }
    names
}

/// Verify `lib_rs` against three allowlists. Returns a `VerifyReport`
/// on success, or one of two `VerifyError` variants — `Unclassified`
/// when the SDK exposes a `pub fn` not placed in any list, and
/// `Duplicate` when the lists themselves overlap.
pub fn verify_cover(
    lib_rs: &str,
    c1_cover: &[&str],
    c2_deferred: &[&str],
    permanent_skip: &[&str],
) -> Result<VerifyReport, VerifyError> {
    // Duplicate detection runs first — overlapping lists make the
    // coverage % meaningless and would also poison `Unclassified` lookup.
    let mut where_listed: BTreeMap<&str, &'static str> = BTreeMap::new();
    for (list_name, list) in [
        ("C1_COVER", c1_cover),
        ("C2_DEFERRED", c2_deferred),
        ("PERMANENT_SKIP", permanent_skip),
    ] {
        for name in list {
            if let Some(prev) = where_listed.insert(name, list_name) {
                return Err(VerifyError::Duplicate {
                    name: (*name).to_string(),
                    first_list: prev,
                    second_list: list_name,
                });
            }
        }
    }

    let pub_fns = grep_pub_fn(lib_rs);
    let mut unclassified = Vec::new();
    for fn_name in &pub_fns {
        if !where_listed.contains_key(fn_name.as_str()) {
            unclassified.push(fn_name.clone());
        }
    }
    if !unclassified.is_empty() {
        unclassified.sort();
        unclassified.dedup();
        return Err(VerifyError::Unclassified(unclassified));
    }

    Ok(VerifyReport::from_counts(
        pub_fns.len(),
        c1_cover.len(),
        c2_deferred.len(),
        permanent_skip.len(),
    ))
}

/// Convenience entry point for the selftest example: panic with an
/// AI-readable message on either error, otherwise return the report.
/// The selftest binary calls this with `include_str!("../../src/lib.rs")`
/// so any verifier failure aborts before any sim work begins.
pub fn verify_cover_or_panic(lib_rs: &str) -> VerifyReport {
    match verify_cover(lib_rs, C1_COVER, C2_DEFERRED, PERMANENT_SKIP) {
        Ok(report) => report,
        Err(VerifyError::Unclassified(names)) => {
            panic!(
                "v5.0 selftest cover allowlist out of sync with `smix-sdk/src/lib.rs`. \
                 Found {} unclassified pub fn: {:?}. \
                 Add each to crates/smix-sdk/src/selftest/verifier.rs (C1_COVER if \
                 the scenario exercises it this cycle, C2_DEFERRED if it is on the \
                 plan-cold next-checkpoint list, PERMANENT_SKIP if it is a \
                 builder/getter).",
                names.len(),
                names,
            )
        }
        Err(VerifyError::Duplicate {
            name,
            first_list,
            second_list,
        }) => panic!(
            "v5.0 selftest cover allowlist curation bug: `{name}` appears in both \
             `{first_list}` and `{second_list}`. Remove from one of them — overlapping \
             lists make coverage % meaningless."
        ),
    }
}