From d9b6528e266dc592ab5ab8a4a354821ed5f9ded8 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 13:41:36 +0800 Subject: [PATCH 01/22] test(sdk): complete lifecycle error vectors Cover every start/event and WIT guest-error combination from the shared lifecycle suite, and drive the real guest fixture and host runtime from those stable vectors. Refs: PP-M7, FR-PLUGIN-01, F11, NFR-MAINT-01 Tests: cargo fmt --all -- --check; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked; RUSTC_WRAPPER= cargo test -p floatile-cli --test conformance_cmd --locked; RUSTC_WRAPPER= cargo test -p floatile-sdk --test conformance --locked; RUSTC_WRAPPER= cargo test -p floatile-runtime --test security sdk_lifecycle_errors_reach_the_host_as_rejections --locked Unverified: TypeScript SDK consumer --- conformance/sdk-lifecycle-v1.json | 21 +++++++++++++++ crates/floatile-cli/src/conformance.rs | 2 +- crates/floatile-cli/tests/conformance_cmd.rs | 2 +- docs/plugin-sdk/conformance-kit.md | 2 +- plugins/evil-wasm/src/lib.rs | 27 +++++++++++++------- 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/conformance/sdk-lifecycle-v1.json b/conformance/sdk-lifecycle-v1.json index ead4fb8..d688363 100644 --- a/conformance/sdk-lifecycle-v1.json +++ b/conformance/sdk-lifecycle-v1.json @@ -2,6 +2,13 @@ "schemaVersion": 1, "engineApiVersion": "1.2.0", "vectors": [ + { + "id": "start-invalid-input", + "callback": "start", + "guestError": "invalid-input", + "message": "conformance start invalid input", + "expectedHostOutcome": "rejected" + }, { "id": "start-rejected", "callback": "start", @@ -9,10 +16,24 @@ "message": "conformance start rejection", "expectedHostOutcome": "rejected" }, + { + "id": "start-internal", + "callback": "start", + "guestError": "internal", + "message": null, + "expectedHostOutcome": "rejected" + }, { "id": "event-invalid-input", "callback": "event", "guestError": "invalid-input", + "message": "conformance event invalid input", + "expectedHostOutcome": "rejected" + }, + { + "id": "event-rejected", + "callback": "event", + "guestError": "rejected", "message": "conformance event rejection", "expectedHostOutcome": "rejected" }, diff --git a/crates/floatile-cli/src/conformance.rs b/crates/floatile-cli/src/conformance.rs index a1d266e..db91083 100644 --- a/crates/floatile-cli/src/conformance.rs +++ b/crates/floatile-cli/src/conformance.rs @@ -121,7 +121,7 @@ mod tests { assert_eq!(report.suite, "sdk-lifecycle-v1"); assert_eq!(report.contract.schema_version, 1); assert_eq!(report.contract.engine_api_version, "1.2.0"); - assert_eq!(report.contract.vectors.len(), 3); + assert_eq!(report.contract.vectors.len(), 6); Ok(()) } } diff --git a/crates/floatile-cli/tests/conformance_cmd.rs b/crates/floatile-cli/tests/conformance_cmd.rs index 5dfcd4d..8a6b19d 100644 --- a/crates/floatile-cli/tests/conformance_cmd.rs +++ b/crates/floatile-cli/tests/conformance_cmd.rs @@ -20,7 +20,7 @@ fn conformance_command_exposes_the_versioned_lifecycle_suite() { assert_eq!(report["status"], "ok"); assert_eq!(report["suite"], "sdk-lifecycle-v1"); assert_eq!(report["contract"]["engineApiVersion"], "1.2.0"); - assert_eq!(report["contract"]["vectors"].as_array().unwrap().len(), 3); + assert_eq!(report["contract"]["vectors"].as_array().unwrap().len(), 6); assert_eq!(report["warnings"], serde_json::json!([])); } diff --git a/docs/plugin-sdk/conformance-kit.md b/docs/plugin-sdk/conformance-kit.md index 235eef6..679464b 100644 --- a/docs/plugin-sdk/conformance-kit.md +++ b/docs/plugin-sdk/conformance-kit.md @@ -18,7 +18,7 @@ - `message`:该 variant 携带的稳定测试 payload,`internal` 为 `null`; - `expectedHostOutcome`:宿主错误分类,不是语言专用异常名。 -当前向量覆盖 `invalid-input`、`rejected` 与 `internal` 全部 WIT guest error。runtime 使用真实 +当前向量覆盖 `start/event × invalid-input/rejected/internal` 的完整组合。runtime 使用真实 WASM Component 执行同一批向量,必须把它们分类为 guest `Rejected`,并证明随后仍可启动和停止同行 实例。trap、fuel、epoch timeout、内存、Broker deny、Operation 和 State Patch 的既有安全测试将在后续 PP-M7 切片逐步登记为同目录的版本化向量。 diff --git a/plugins/evil-wasm/src/lib.rs b/plugins/evil-wasm/src/lib.rs index f3cfb3f..6273826 100644 --- a/plugins/evil-wasm/src/lib.rs +++ b/plugins/evil-wasm/src/lib.rs @@ -77,8 +77,8 @@ impl Widget for Evil { } fn start(&mut self, ctx: &mut Context) -> WidgetResult { - if self.mode == "conformance-start-rejected" { - return Err(WidgetError::Rejected("conformance start rejection".into())); + if let Some(error) = conformance_error(&self.mode, "start") { + return Err(error); } match self.mode.as_str() { // loop 不进 start:让实例先成功启动,测试再以事件触发无限循环,fuel 才 trap。 @@ -96,13 +96,8 @@ impl Widget for Evil { } fn event(&mut self, event: Self::Event, ctx: &mut Context) -> WidgetResult { - if self.mode == "conformance-event-invalid-input" { - return Err(WidgetError::InvalidInput( - "conformance event rejection".into(), - )); - } - if self.mode == "conformance-event-internal" { - return Err(WidgetError::Internal); + if let Some(error) = conformance_error(&self.mode, "event") { + return Err(error); } match event { EvilEvent::Trigger => match self.mode.as_str() { @@ -122,6 +117,20 @@ impl Widget for Evil { } } +fn conformance_error(mode: &str, callback: &str) -> Option { + let error = mode.strip_prefix(&format!("conformance-{callback}-"))?; + match error { + "invalid-input" => Some(WidgetError::InvalidInput(format!( + "conformance {callback} invalid input" + ))), + "rejected" => Some(WidgetError::Rejected(format!( + "conformance {callback} rejection" + ))), + "internal" => Some(WidgetError::Internal), + _ => None, + } +} + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] impl Evil { /// 调用两个未声明能力;每次失败都记录(固有能力 log:write 会成功并留审计)。 From e2b06ff7ff79f5f3bc92cdc04466830fc32b5050 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 13:44:47 +0800 Subject: [PATCH 02/22] test(sdk): share security conformance vectors Expose language-independent Broker denial, invalid patch, fuel, timeout, and memory isolation outcomes through the conformance command, and drive the real host security tests from the same vectors. Refs: PP-M7, FR-PLUGIN-01, F11, NFR-MAINT-01 Tests: cargo fmt --all -- --check; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked; RUSTC_WRAPPER= cargo test -p floatile-cli conformance --locked; RUSTC_WRAPPER= cargo test -p floatile-sdk --test conformance --locked; RUSTC_WRAPPER= cargo test -p floatile-runtime --test security --locked Unverified: TypeScript SDK consumer --- conformance/sdk-security-v1.json | 41 +++++++++ crates/floatile-cli/src/conformance.rs | 67 ++++++++++++++ crates/floatile-cli/src/main.rs | 9 ++ crates/floatile-cli/tests/conformance_cmd.rs | 7 ++ crates/floatile-runtime/tests/security.rs | 96 +++++++++++++++++++- crates/floatile-sdk/tests/conformance.rs | 47 ++++++++++ docs/plugin-sdk/conformance-kit.md | 6 ++ 7 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 conformance/sdk-security-v1.json diff --git a/conformance/sdk-security-v1.json b/conformance/sdk-security-v1.json new file mode 100644 index 0000000..ea77184 --- /dev/null +++ b/conformance/sdk-security-v1.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "engineApiVersion": "1.2.0", + "vectors": [ + { + "id": "broker-deny", + "mode": "deny", + "trigger": "start", + "expectedHostOutcome": "completed", + "hostSurvives": true + }, + { + "id": "invalid-state-patch", + "mode": "bad-patch", + "trigger": "start", + "expectedHostOutcome": "completed-without-state-update", + "hostSurvives": true + }, + { + "id": "fuel-exhaustion", + "mode": "loop", + "trigger": "event", + "expectedHostOutcome": "failed", + "hostSurvives": true + }, + { + "id": "wall-clock-timeout", + "mode": "loop", + "trigger": "event", + "expectedHostOutcome": "timed-out", + "hostSurvives": true + }, + { + "id": "memory-limit", + "mode": "alloc", + "trigger": "start", + "expectedHostOutcome": "failed", + "hostSurvives": true + } + ] +} diff --git a/crates/floatile-cli/src/conformance.rs b/crates/floatile-cli/src/conformance.rs index db91083..f6d8e32 100644 --- a/crates/floatile-cli/src/conformance.rs +++ b/crates/floatile-cli/src/conformance.rs @@ -11,6 +11,10 @@ pub const LIFECYCLE_SUITE_JSON: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../conformance/sdk-lifecycle-v1.json" )); +pub const SECURITY_SUITE_JSON: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-security-v1.json" +)); #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -30,6 +34,24 @@ pub struct LifecycleVector { pub expected_host_outcome: String, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SecuritySuite { + pub schema_version: u32, + pub engine_api_version: String, + pub vectors: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SecurityVector { + pub id: String, + pub mode: String, + pub trigger: String, + pub expected_host_outcome: String, + pub host_survives: bool, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ConformanceReport { @@ -37,6 +59,7 @@ pub struct ConformanceReport { pub status: &'static str, pub suite: &'static str, pub contract: LifecycleSuite, + pub security_contract: SecuritySuite, pub warnings: Vec, } @@ -64,15 +87,57 @@ pub fn lifecycle_report() -> Result { let contract: LifecycleSuite = serde_json::from_str(LIFECYCLE_SUITE_JSON).map_err(|_| ConformanceError::InvalidJson)?; validate(&contract)?; + let security_contract: SecuritySuite = + serde_json::from_str(SECURITY_SUITE_JSON).map_err(|_| ConformanceError::InvalidJson)?; + validate_security(&security_contract)?; + if security_contract.engine_api_version != contract.engine_api_version { + return Err(ConformanceError::InvalidVector( + "conformance contracts 的 engineApiVersion 不一致".to_owned(), + )); + } Ok(ConformanceReport { schema_version: OUTPUT_SCHEMA_VERSION, status: "ok", suite: "sdk-lifecycle-v1", contract, + security_contract, warnings: Vec::new(), }) } +fn validate_security(suite: &SecuritySuite) -> Result<(), ConformanceError> { + if suite.schema_version != 1 { + return Err(ConformanceError::UnsupportedSchema(suite.schema_version)); + } + let mut ids = BTreeSet::new(); + for vector in &suite.vectors { + if !ids.insert(vector.id.as_str()) { + return Err(ConformanceError::InvalidVector( + "security vector id 重复".to_owned(), + )); + } + if !matches!(vector.trigger.as_str(), "start" | "event") { + return Err(ConformanceError::InvalidVector( + "security trigger 不受支持".to_owned(), + )); + } + if !matches!( + vector.expected_host_outcome.as_str(), + "completed" | "completed-without-state-update" | "failed" | "timed-out" + ) { + return Err(ConformanceError::InvalidVector( + "security expectedHostOutcome 不受支持".to_owned(), + )); + } + if !vector.host_survives { + return Err(ConformanceError::InvalidVector( + "security vector 必须要求 hostSurvives".to_owned(), + )); + } + } + Ok(()) +} + fn validate(suite: &LifecycleSuite) -> Result<(), ConformanceError> { if suite.schema_version != 1 { return Err(ConformanceError::UnsupportedSchema(suite.schema_version)); @@ -122,6 +187,8 @@ mod tests { assert_eq!(report.contract.schema_version, 1); assert_eq!(report.contract.engine_api_version, "1.2.0"); assert_eq!(report.contract.vectors.len(), 6); + assert_eq!(report.security_contract.schema_version, 1); + assert_eq!(report.security_contract.vectors.len(), 5); Ok(()) } } diff --git a/crates/floatile-cli/src/main.rs b/crates/floatile-cli/src/main.rs index 5e2e21f..f9e5f14 100644 --- a/crates/floatile-cli/src/main.rs +++ b/crates/floatile-cli/src/main.rs @@ -111,6 +111,15 @@ fn cmd_conformance(args: &[String]) -> ExitCode { vector.expected_host_outcome ); } + for vector in report.security_contract.vectors { + println!( + " {} trigger={} outcome={} host-survives={}", + vector.id, + vector.trigger, + vector.expected_host_outcome, + vector.host_survives + ); + } } ExitCode::SUCCESS } diff --git a/crates/floatile-cli/tests/conformance_cmd.rs b/crates/floatile-cli/tests/conformance_cmd.rs index 8a6b19d..73e5790 100644 --- a/crates/floatile-cli/tests/conformance_cmd.rs +++ b/crates/floatile-cli/tests/conformance_cmd.rs @@ -21,6 +21,13 @@ fn conformance_command_exposes_the_versioned_lifecycle_suite() { assert_eq!(report["suite"], "sdk-lifecycle-v1"); assert_eq!(report["contract"]["engineApiVersion"], "1.2.0"); assert_eq!(report["contract"]["vectors"].as_array().unwrap().len(), 6); + assert_eq!( + report["securityContract"]["vectors"] + .as_array() + .unwrap() + .len(), + 5 + ); assert_eq!(report["warnings"], serde_json::json!([])); } diff --git a/crates/floatile-runtime/tests/security.rs b/crates/floatile-runtime/tests/security.rs index e28e801..db61b20 100644 --- a/crates/floatile-runtime/tests/security.rs +++ b/crates/floatile-runtime/tests/security.rs @@ -45,6 +45,24 @@ struct LifecycleVector { expected_host_outcome: String, } +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecuritySuite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecurityVector { + id: String, + mode: String, + trigger: String, + expected_host_outcome: String, + host_survives: bool, +} + fn lifecycle_suite() -> LifecycleSuite { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -53,6 +71,31 @@ fn lifecycle_suite() -> LifecycleSuite { .expect("lifecycle conformance vectors must parse") } +fn security_suite() -> &'static SecuritySuite { + static SUITE: std::sync::OnceLock = std::sync::OnceLock::new(); + SUITE.get_or_init(|| { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-security-v1.json" + ))) + .expect("security conformance vectors must parse") + }) +} + +fn security_vector(id: &str) -> &'static SecurityVector { + let suite = security_suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!( + suite.engine_api_version, + floatile_plugin_api::ENGINE_API_VERSION + ); + suite + .vectors + .iter() + .find(|vector| vector.id == id) + .unwrap_or_else(|| panic!("missing security vector {id}")) +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -164,11 +207,20 @@ fn mem_audit_persistence() -> (Arc>, floatile_services::AuditListen /// 未声明能力调用 → Broker 拒绝 + 审计落 SQLite + 宿主存活。 #[tokio::test(flavor = "multi_thread")] async fn denied_capability_persists_audit_and_host_survives() { + let vector = security_vector("broker-deny"); + assert_eq!(vector.trigger, "start"); + assert_eq!(vector.expected_host_outcome, "completed"); + assert!(vector.host_survives); let (store, listener) = mem_audit_persistence(); let manager = WidgetManager::new() .unwrap() .with_audit_listener(Some(listener)); - let handle = spawn_evil(&manager, 1, evil_grants_none(1), json!({"mode": "deny"})); + let handle = spawn_evil( + &manager, + 1, + evil_grants_none(1), + json!({"mode": vector.mode}), + ); // 所有未声明能力调用被 Broker 拒绝并被插件吞掉(不中断实例)。 handle.start().await.expect("deny 模式 start 应成功"); @@ -269,12 +321,19 @@ async fn storage_operation_round_trips_through_guest_contract() { /// 超限/类型错误/未知字段 State Patch → 被拒,宿主存活,状态不部分改写。 #[tokio::test(flavor = "multi_thread")] async fn bad_state_patch_rejected_and_host_survives() { + let vector = security_vector("invalid-state-patch"); + assert_eq!(vector.trigger, "start"); + assert_eq!( + vector.expected_host_outcome, + "completed-without-state-update" + ); + assert!(vector.host_survives); let manager = WidgetManager::new().unwrap(); let mut handle = spawn_evil( &manager, 2, evil_grants_none(2), - json!({"mode": "bad-patch"}), + json!({"mode": vector.mode}), ); // 三类恶意 patch 全部被宿主拒绝;error 被插件吞掉,实例不中断。 handle.start().await.expect("bad-patch 模式 start 应成功"); @@ -301,9 +360,18 @@ async fn bad_state_patch_rejected_and_host_survives() { /// 无限 CPU 循环 → fuel 耗尽 trap 终止实例;宿主与其他实例存活。 #[tokio::test(flavor = "multi_thread")] async fn infinite_loop_is_fuel_trapped_and_host_survives() { + let vector = security_vector("fuel-exhaustion"); + assert_eq!(vector.trigger, "event"); + assert_eq!(vector.expected_host_outcome, "failed"); + assert!(vector.host_survives); // 事件触发无限循环;实例共享单一 fuel 预算,循环会耗尽余量并 trap。 let manager = WidgetManager::new().unwrap(); - let handle = spawn_evil(&manager, 3, evil_grants_none(3), json!({"mode": "loop"})); + let handle = spawn_evil( + &manager, + 3, + evil_grants_none(3), + json!({"mode": vector.mode}), + ); handle .start() .await @@ -331,11 +399,20 @@ async fn infinite_loop_is_fuel_trapped_and_host_survives() { /// 超大 fuel 不能绕过墙钟预算;epoch deadline 必须及时终止本实例。 #[tokio::test(flavor = "multi_thread")] async fn infinite_loop_is_wall_clock_timed_out_and_peer_survives() { + let vector = security_vector("wall-clock-timeout"); + assert_eq!(vector.trigger, "event"); + assert_eq!(vector.expected_host_outcome, "timed-out"); + assert!(vector.host_survives); let manager = WidgetManager::new() .unwrap() .with_fuel_per_call(u64::MAX) .with_call_timeout(Duration::from_millis(25)); - let handle = spawn_evil(&manager, 11, evil_grants_none(11), json!({"mode": "loop"})); + let handle = spawn_evil( + &manager, + 11, + evil_grants_none(11), + json!({"mode": vector.mode}), + ); handle.start().await.expect("loop 模式 start 应成功"); let peer = spawn_evil(&manager, 12, evil_grants_none(12), json!({"mode": "deny"})); peer.start().await.expect("同一引擎的其他实例应启动"); @@ -372,8 +449,17 @@ async fn infinite_loop_is_wall_clock_timed_out_and_peer_survives() { /// 超限线性内存申请 → StoreLimits 终止实例;宿主存活。 #[tokio::test(flavor = "multi_thread")] async fn memory_alloc_over_limit_traps_instance_but_host_survives() { + let vector = security_vector("memory-limit"); + assert_eq!(vector.trigger, "start"); + assert_eq!(vector.expected_host_outcome, "failed"); + assert!(vector.host_survives); let manager = WidgetManager::new().unwrap(); // 默认每实例 16 MiB 上限 - let handle = spawn_evil(&manager, 4, evil_grants_none(4), json!({"mode": "alloc"})); + let handle = spawn_evil( + &manager, + 4, + evil_grants_none(4), + json!({"mode": vector.mode}), + ); // start 里申请 64 MiB > 16 MiB 上限 → 实例 trap,start 失败。 let start_result = handle.start().await; assert!( diff --git a/crates/floatile-sdk/tests/conformance.rs b/crates/floatile-sdk/tests/conformance.rs index a762cc7..b1275ca 100644 --- a/crates/floatile-sdk/tests/conformance.rs +++ b/crates/floatile-sdk/tests/conformance.rs @@ -23,6 +23,23 @@ struct Vector { expected_host_outcome: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecuritySuite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecurityVector { + id: String, + trigger: String, + expected_host_outcome: String, + host_survives: bool, +} + fn suite() -> Suite { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -50,6 +67,14 @@ fn widget_error(vector: &Vector) -> WidgetError { } } +fn security_suite() -> SecuritySuite { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-security-v1.json" + ))) + .expect("SDK security conformance vectors must be valid JSON") +} + #[test] fn lifecycle_vectors_match_the_generated_wit_contract() { let suite = suite(); @@ -74,3 +99,25 @@ fn lifecycle_vectors_match_the_generated_wit_contract() { "vectors must cover every WIT widget-error variant" ); } + +#[test] +fn security_vectors_are_language_independent_and_host_survival_is_mandatory() { + let suite = security_suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!(suite.engine_api_version, ENGINE_API_VERSION); + assert_eq!(suite.vectors.len(), 5); + + let mut ids = BTreeSet::new(); + for vector in suite.vectors { + assert!(ids.insert(vector.id), "duplicate security vector id"); + assert!(matches!(vector.trigger.as_str(), "start" | "event")); + assert!(matches!( + vector.expected_host_outcome.as_str(), + "completed" | "completed-without-state-update" | "failed" | "timed-out" + )); + assert!( + vector.host_survives, + "security vectors must preserve host survival" + ); + } +} diff --git a/docs/plugin-sdk/conformance-kit.md b/docs/plugin-sdk/conformance-kit.md index 679464b..c0bbf4e 100644 --- a/docs/plugin-sdk/conformance-kit.md +++ b/docs/plugin-sdk/conformance-kit.md @@ -29,3 +29,9 @@ PP-M7 切片逐步登记为同目录的版本化向量。 `floatile conformance --json --no-interactive` 会校验 CLI 内嵌的当前 suite 并输出稳定的 schema v1 报告;language adapter、CI 与 Agent 应消费该命令,而不是依赖仓库路径。`--deny-warnings` 与其他作者 命令语义一致;当前 suite 没有 warning。 + +## 安全与隔离向量 + +`sdk-security-v1.json` 固定 Broker 默认拒绝、非法 State Patch 原子拒绝、fuel 耗尽、墙钟超时和 +线性内存上限五类宿主结果。每条向量都要求同行实例或后继实例存活;语言 adapter 不得把 trap、超时 +或内存失败降级成业务拒绝,也不得用语言专用 mock 代替真实 Component/host 执行。 From 4e9215589cc69a2a642ff5da1ef2cc99636255a0 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 13:50:01 +0800 Subject: [PATCH 03/22] feat(cli): add explicit SDK project migration Make the author SDK language and version explicit for the dual-language project model while preserving legacy Rust projects. Add a read-only-by-default migrate command with recoverable writes, stable diagnostics, and idempotent automation output. Refs: PP-M7, FR-PLUGIN-01, F11, NFR-MAINT-01 Tests: cargo fmt --all -- --check; RUSTC_WRAPPER= cargo test -p floatile-cli --lib migrate:: --locked; RUSTC_WRAPPER= cargo test -p floatile-cli --test migrate_cmd --locked; RUSTC_WRAPPER= cargo test -p floatile-cli --lib project:: --locked; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked Unverified: Windows filesystem failure injection during recoverable replacement --- crates/floatile-cli/src/lib.rs | 4 +- crates/floatile-cli/src/main.rs | 42 ++++- crates/floatile-cli/src/migrate.rs | 190 ++++++++++++++++++++ crates/floatile-cli/src/project.rs | 38 +++- crates/floatile-cli/tests/migrate_cmd.rs | 84 +++++++++ docs/plugin-sdk/manifest-v1.md | 8 + docs/plugin-sdk/sdk-developer-experience.md | 6 +- 7 files changed, 364 insertions(+), 8 deletions(-) create mode 100644 crates/floatile-cli/src/migrate.rs create mode 100644 crates/floatile-cli/tests/migrate_cmd.rs diff --git a/crates/floatile-cli/src/lib.rs b/crates/floatile-cli/src/lib.rs index 0c08715..e00250e 100644 --- a/crates/floatile-cli/src/lib.rs +++ b/crates/floatile-cli/src/lib.rs @@ -10,6 +10,7 @@ pub mod dev; pub mod inspect; pub mod install; pub mod instance; +pub mod migrate; pub mod output; pub mod package; pub mod preview; @@ -31,11 +32,12 @@ pub use instance::{ InstanceCommandError, InstanceView, configure_instance, create_instance, delete_instance, get_instance, list_instances, rollback_instance, set_instance_desired_state, }; +pub use migrate::{MigrationError, MigrationReport, migrate_project}; pub use output::{CommandErrorReport, CommandWarning, OUTPUT_SCHEMA_VERSION}; pub use package::{PackageError, PackageLimits, ValidatedPackage, validate_package}; pub use preview::{PreviewError, PreviewReport, PreviewSession, preview_project}; pub use project::{ - ProjectConfig, ProjectError, generate_manifest, generate_template, parse_floatile_toml, + ProjectConfig, ProjectError, SdkCfg, generate_manifest, generate_template, parse_floatile_toml, }; pub use run::{RunError, RunReport, default_run_paths, run_project}; pub use test::{ diff --git a/crates/floatile-cli/src/main.rs b/crates/floatile-cli/src/main.rs index f9e5f14..b32b089 100644 --- a/crates/floatile-cli/src/main.rs +++ b/crates/floatile-cli/src/main.rs @@ -5,8 +5,8 @@ use std::process::ExitCode; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use floatile_cli::{ - CommandErrorReport, build, check, conformance, dev, inspect, install, instance, package, - preview, project, run, test, trust, + CommandErrorReport, build, check, conformance, dev, inspect, install, instance, migrate, + package, preview, project, run, test, trust, }; use floatile_core::{InstanceConfig, InstanceDesiredState, InstanceId, PermissionChangeKind}; @@ -14,7 +14,7 @@ fn main() -> ExitCode { let args: Vec = std::env::args().collect(); if args.len() < 2 { eprintln!( - "用法: floatile [参数]" + "用法: floatile [参数]" ); return ExitCode::from(2); } @@ -32,6 +32,7 @@ fn main() -> ExitCode { "preview" => cmd_preview(&args[2..]), "run" => cmd_run(&args[2..]), "schema" => cmd_schema(&args[2..]), + "migrate" => cmd_migrate(&args[2..]), "conformance" => cmd_conformance(&args[2..]), other => { eprintln!("未知命令: {other}"); @@ -40,6 +41,41 @@ fn main() -> ExitCode { } } +fn cmd_migrate(args: &[String]) -> ExitCode { + let json = args.iter().any(|argument| argument == "--json"); + let write = args.iter().any(|argument| argument == "--write"); + let positionals = match author_positionals(args, &[], &["--write"], 1) { + Ok(positionals) => positionals, + Err(detail) => return render_basic_error("FMIGRATE_ARGUMENT", &detail, json, true), + }; + let project_dir = positionals + .first() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + match migrate::migrate_project(&project_dir, write) { + Ok(report) => { + if json { + println!("{}", serialize_json(&report)); + } else { + println!( + "migrate: PASS mode={} changes={} changed={}", + report.mode, + report.changes.len(), + report.changed + ); + for change in report.changes { + println!( + " code={} file={} {}", + change.code, change.file, change.description + ); + } + } + ExitCode::SUCCESS + } + Err(error) => render_basic_error(error.code(), &error.to_string(), json, false), + } +} + fn cmd_trust(args: &[String]) -> ExitCode { let json = args.iter().any(|argument| argument == "--json"); let positionals = match author_positionals(args, &["--db"], &[], 3) { diff --git a/crates/floatile-cli/src/migrate.rs b/crates/floatile-cli/src/migrate.rs new file mode 100644 index 0000000..ca004f1 --- /dev/null +++ b/crates/floatile-cli/src/migrate.rs @@ -0,0 +1,190 @@ +//! Versioned author-project migrations. The default mode is read-only. + +use std::io::Write; +use std::path::Path; + +use serde::Serialize; +use thiserror::Error; + +use crate::OUTPUT_SCHEMA_VERSION; +use crate::project::parse_floatile_toml; + +const MAX_PROJECT_CONFIG_BYTES: u64 = 256 * 1024; +const SDK_SECTION: &str = "\n[sdk]\nlanguage = \"rust\"\nversion = \"0.1.0\"\n"; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationChange { + pub code: &'static str, + pub file: &'static str, + pub description: &'static str, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MigrationReport { + pub schema_version: u32, + pub status: &'static str, + pub mode: &'static str, + pub changed: bool, + pub target_sdk_version: &'static str, + pub changes: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Error)] +pub enum MigrationError { + #[error("项目配置缺失")] + MissingConfig, + #[error("项目配置不是普通文件")] + UnsafeConfig, + #[error("项目配置超过迁移预算")] + ConfigTooLarge, + #[error("项目配置无效")] + InvalidConfig, + #[error("无法读取项目配置")] + Read, + #[error("无法写入项目配置")] + Write, +} + +impl MigrationError { + pub fn code(&self) -> &'static str { + match self { + Self::MissingConfig => "FMIGRATE_CONFIG_MISSING", + Self::UnsafeConfig => "FMIGRATE_CONFIG_UNSAFE", + Self::ConfigTooLarge => "FMIGRATE_CONFIG_SIZE", + Self::InvalidConfig => "FMIGRATE_CONFIG_INVALID", + Self::Read => "FMIGRATE_READ", + Self::Write => "FMIGRATE_WRITE", + } + } +} + +pub fn migrate_project(project_dir: &Path, write: bool) -> Result { + let path = project_dir.join("floatile.toml"); + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + MigrationError::MissingConfig + } else { + MigrationError::Read + } + })?; + if !metadata.file_type().is_file() { + return Err(MigrationError::UnsafeConfig); + } + if metadata.len() > MAX_PROJECT_CONFIG_BYTES { + return Err(MigrationError::ConfigTooLarge); + } + + let source = std::fs::read_to_string(&path).map_err(|_| MigrationError::Read)?; + parse_floatile_toml(&source).map_err(|_| MigrationError::InvalidConfig)?; + let document: toml::Value = + toml::from_str(&source).map_err(|_| MigrationError::InvalidConfig)?; + let needs_sdk = document.get("sdk").is_none(); + let changes = if needs_sdk { + vec![MigrationChange { + code: "FMIGRATE_SDK_EXPLICIT", + file: "floatile.toml", + description: "显式记录 Rust SDK language/version,为双语言构建保留单一项目事实", + }] + } else { + Vec::new() + }; + + if write && needs_sdk { + let mut migrated = source; + if !migrated.ends_with('\n') { + migrated.push('\n'); + } + migrated.push_str(SDK_SECTION.trim_start_matches('\n')); + replace_recoverably(&path, migrated.as_bytes())?; + } + + Ok(MigrationReport { + schema_version: OUTPUT_SCHEMA_VERSION, + status: "ok", + mode: if write { "write" } else { "dry-run" }, + changed: write && needs_sdk, + target_sdk_version: env!("CARGO_PKG_VERSION"), + changes, + warnings: Vec::new(), + }) +} + +fn replace_recoverably(path: &Path, bytes: &[u8]) -> Result<(), MigrationError> { + let parent = path.parent().ok_or(MigrationError::Write)?; + let suffix = std::process::id(); + let staging = parent.join(format!(".floatile.toml.migrate-{suffix}")); + let backup = parent.join(format!(".floatile.toml.backup-{suffix}")); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&staging) + .map_err(|_| MigrationError::Write)?; + if file.write_all(bytes).is_err() || file.sync_all().is_err() { + let _ = std::fs::remove_file(&staging); + return Err(MigrationError::Write); + } + drop(file); + + if std::fs::rename(path, &backup).is_err() { + let _ = std::fs::remove_file(&staging); + return Err(MigrationError::Write); + } + if std::fs::rename(&staging, path).is_err() { + let _ = std::fs::rename(&backup, path); + let _ = std::fs::remove_file(&staging); + return Err(MigrationError::Write); + } + std::fs::remove_file(&backup).map_err(|_| MigrationError::Write) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + fn legacy_project() -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "floatile-migrate-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("floatile.toml"), + "[plugin]\nid = \"dev.floatile.legacy\"\nname = \"Legacy\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + dir + } + + #[test] + fn dry_run_reports_without_writing() { + let dir = legacy_project(); + let before = std::fs::read_to_string(dir.join("floatile.toml")).unwrap(); + let report = migrate_project(&dir, false).unwrap(); + assert_eq!(report.mode, "dry-run"); + assert!(!report.changed); + assert_eq!(report.changes.len(), 1); + assert_eq!( + std::fs::read_to_string(dir.join("floatile.toml")).unwrap(), + before + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn write_adds_explicit_sdk_once() { + let dir = legacy_project(); + let report = migrate_project(&dir, true).unwrap(); + assert!(report.changed); + let migrated = std::fs::read_to_string(dir.join("floatile.toml")).unwrap(); + assert!(migrated.contains("[sdk]")); + assert_eq!(migrate_project(&dir, true).unwrap().changes.len(), 0); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/floatile-cli/src/project.rs b/crates/floatile-cli/src/project.rs index f5e59fb..98634be 100644 --- a/crates/floatile-cli/src/project.rs +++ b/crates/floatile-cli/src/project.rs @@ -22,6 +22,8 @@ use serde::Deserialize; pub struct ProjectConfig { pub plugin: PluginCfg, #[serde(default)] + pub sdk: SdkCfg, + #[serde(default)] pub widget: WidgetCfg, #[serde(default)] pub permissions: BTreeMap, @@ -31,6 +33,31 @@ pub struct ProjectConfig { pub publisher: Option, } +#[derive(Debug, Clone, Deserialize)] +pub struct SdkCfg { + #[serde(default = "default_sdk_language")] + pub language: String, + #[serde(default = "default_sdk_version")] + pub version: String, +} + +impl Default for SdkCfg { + fn default() -> Self { + Self { + language: default_sdk_language(), + version: default_sdk_version(), + } + } +} + +fn default_sdk_language() -> String { + "rust".to_owned() +} + +fn default_sdk_version() -> String { + env!("CARGO_PKG_VERSION").to_owned() +} + #[derive(Debug, Clone, Deserialize)] pub struct PluginCfg { pub id: String, @@ -208,8 +235,8 @@ pub fn generate_manifest(config: &ProjectConfig) -> Result std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "floatile-migrate-cmd-{}-{label}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("floatile.toml"), + "[plugin]\nid = \"dev.floatile.legacy\"\nname = \"Legacy\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + dir +} + +#[test] +fn migrate_defaults_to_read_only_dry_run() { + let dir = legacy_project("dry-run"); + let before = std::fs::read_to_string(dir.join("floatile.toml")).unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args([ + "migrate", + dir.to_str().unwrap(), + "--json", + "--no-interactive", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {:?}", output.stderr); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["mode"], "dry-run"); + assert_eq!(report["changed"], false); + assert_eq!(report["changes"][0]["code"], "FMIGRATE_SDK_EXPLICIT"); + assert_eq!( + std::fs::read_to_string(dir.join("floatile.toml")).unwrap(), + before + ); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn migrate_write_is_explicit_and_idempotent() { + let dir = legacy_project("write"); + let output = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args([ + "migrate", + dir.to_str().unwrap(), + "--write", + "--json", + "--no-interactive", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "stderr: {:?}", output.stderr); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["mode"], "write"); + assert_eq!(report["changed"], true); + let migrated = std::fs::read_to_string(dir.join("floatile.toml")).unwrap(); + assert_eq!(migrated.matches("[sdk]").count(), 1); + + let second = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args(["migrate", dir.to_str().unwrap(), "--write", "--json"]) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&second.stdout).unwrap(); + assert_eq!(report["changed"], false); + assert_eq!(report["changes"], serde_json::json!([])); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn migrate_rejects_unknown_options() { + let output = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args(["migrate", "--apply", "--json"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + let report: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(report["code"], "FMIGRATE_ARGUMENT"); +} diff --git a/docs/plugin-sdk/manifest-v1.md b/docs/plugin-sdk/manifest-v1.md index 6789ac3..70761a2 100644 --- a/docs/plugin-sdk/manifest-v1.md +++ b/docs/plugin-sdk/manifest-v1.md @@ -107,6 +107,10 @@ id = "dev.floatile.clock" name = "World Clock" version = "0.1.0" +[sdk] +language = "rust" +version = "0.1.0" + [widget] default_size = [240, 120] min_size = [160, 80] @@ -117,6 +121,10 @@ max_per_minute = 60 max_active = 2 ``` +`sdk.language` 当前接受作者工具链登记值并写入 manifest `build.sdk`;它只用于构建诊断,不能改变 +权限或运行语义。旧项目缺少 `[sdk]` 时按 `rust@0.1.0` 兼容读取,`floatile migrate` 默认 dry-run +报告补齐动作,只有显式 `--write` 才修改作者配置。 + 不可从代码安全推导的字段必须显式声明。CLI 检出的 capability 只是候选:代码使用但未声明为 error;声明但未使用为 warning;CLI 不自动扩大权限。 diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index d37fc98..5c94cf2 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -227,7 +227,7 @@ Detected capabilities: 退出码与诊断 code 稳定;日志文本不是自动化接口。 -当前已落地:`new/validate/check/dev/test/preview/build/install/trust/run/inspect/instance`;`test` 用 +当前已落地:`new/validate/check/dev/test/preview/build/install/trust/run/inspect/instance/migrate`;`test` 用 `floatile-runtime::harness` 对已构建包跑无头生命周期冒烟。`instance` 子命令提供单一 JSON 结果、稳定 `FINSTANCE_*`/`FCAT_*`/`FCONFIG_*` code,支持 `--db`、`--store`、 `--config`/`--config-file`与 `--no-interactive`。`inspect` 在输出前复用完整包安全校验,提供 manifest、 @@ -269,7 +269,9 @@ State 更新数和 deny 审计数。当前 `advance_time` 使用真实 Tokio 短 Rust SDK 包内包含由根 `wit/floatile-widget.wit` 机械同步的发行快照,仓库测试要求二者逐字节一致; 干净目录测试从 `floatile-sdk`、`floatile-sdk-macros` 与 `floatile-ui-schema` 的独立 Cargo 包快照解析 模板依赖,不使用仓库内部 path。许可 ADR 通过前这些包只用于仓库内可发布性验证,不授权上传 registry。 -`migrate` 仍未实现,属于后续 SDK/API 稳定化工作,不在 PP-M4 Rust 作者闭环范围内。 +`migrate` 使用版本化 JSON 报告且默认只读 dry-run;当前第一条真实迁移将旧项目隐式的 Rust SDK +选择补成 `[sdk] language/version`,为 Rust/TypeScript 共用项目模型建立显式事实。只有 `--write` +修改文件,重复执行无变化;缺失、超限、非普通文件或无效 TOML 使用稳定 `FMIGRATE_*` code 拒绝。 ## 8. 诊断格式 From 99c89883e61490f686fd0ac801ef0ef4fe73fe55 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 13:51:17 +0800 Subject: [PATCH 04/22] docs(sdk): gate API docs and migrations Document the executable SDK migration contract and version-axis boundaries, and make warning-free generated Rust SDK documentation a required Linux CI step. Refs: PP-M7, FR-PLUGIN-01, F11, NFR-MAINT-01 Tests: git diff --check; cargo fmt --all -- --check; RUSTC_WRAPPER= RUSTDOCFLAGS=-D warnings cargo doc -p floatile-sdk -p floatile-sdk-macros -p floatile-ui-schema --no-deps --locked; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked Unverified: GitHub Actions execution of the new documentation step --- .github/workflows/ci.yml | 5 +++ docs/README.md | 2 +- docs/plugin-sdk/migration-guide.md | 46 +++++++++++++++++++++ docs/plugin-sdk/sdk-developer-experience.md | 1 + 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 docs/plugin-sdk/migration-guide.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82616a9..b416571 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,11 @@ jobs: run: cargo check --workspace --exclude floatile-clock-wasm --all-targets --locked - name: Clippy run: cargo clippy --workspace --exclude floatile-clock-wasm --all-targets --locked -- -D warnings + - name: Rust SDK API docs + if: runner.os == 'Linux' + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc -p floatile-sdk -p floatile-sdk-macros -p floatile-ui-schema --no-deps --locked - name: Build WASI guest (hermetic integration tests) # 集成测试读取 target/wasm32-wasip2/debug 下的组件;先构建保证干净 # checkout 上测试不依赖执行顺序或测试内并发构建。 diff --git a/docs/README.md b/docs/README.md index a243972..f81180b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ | 分发与信任 | `architecture/decisions/0005-package-signing-and-trust.md`、`plugin-sdk/manifest-v1.md` | 签名格式、publisher trust、撤销、更新或回滚策略变化 | | 权限与网络安全 | `security/permission-model.md`、`security/http-broker.md` | 能力、scope、配额、脱敏或网络策略变化 | | 插件系统总体架构 | `plugin-sdk/plugin-system-architecture.md` | 数据流、生命周期、实例隔离、语言/runtime 或版本轴变化 | -| 插件 SDK 与开发体验 | `plugin-sdk/sdk-developer-experience.md` | Rust/TypeScript API、组件、CLI、诊断、测试或 Agent 契约变化 | +| 插件 SDK 与开发体验 | `plugin-sdk/sdk-developer-experience.md`、`plugin-sdk/migration-guide.md` | Rust/TypeScript API、组件、CLI、诊断、迁移、测试或 Agent 契约变化 | | SDK 一致性向量 | `plugin-sdk/conformance-kit.md`、`../conformance/` | SDK lifecycle、错误、行为或跨语言 conformance 格式变化 | | 插件契约 | `plugin-sdk/manifest-v1.md`、`plugin-sdk/wit-api-v1.md`、`plugin-sdk/ui-ir-v1.md` | manifest/WIT/UI IR/包格式变化 | | 平台事实 | `platform-matrix/platform-matrix.md` | 获得新的实测证据 | diff --git a/docs/plugin-sdk/migration-guide.md b/docs/plugin-sdk/migration-guide.md new file mode 100644 index 0000000..c267261 --- /dev/null +++ b/docs/plugin-sdk/migration-guide.md @@ -0,0 +1,46 @@ +# Floatile SDK 迁移指南 + +> 状态:Implemented(Rust SDK 0.1 项目模型;TypeScript adapter 尚未实现) +> 范围:PP-M7、FR-PLUGIN-01、F11、NFR-MAINT-01 + +本文记录作者可执行的 SDK、UI API、Engine API 与 manifest 迁移。迁移不得改变安装授权、绕过 +Permission Broker,或直接编辑生成的 `manifest.json`、`widget.ftui` 和 WIT bindings。 + +## 自动迁移契约 + +```text +floatile migrate --json --no-interactive +floatile migrate --write --json --no-interactive +``` + +默认命令是只读 dry-run,输出 `schemaVersion=1`、目标 SDK 版本及有序 change 列表。只有显式 +`--write` 才修改作者文件;重复执行必须返回空 change。CI 应先运行 dry-run,再由作者审查并决定是否 +写入。`--deny-warnings` 可与其他作者命令一致使用。 + +迁移输入视为不可信:`floatile.toml` 必须是小于 256 KiB 的普通 UTF-8 文件并通过当前 parser;缺失、 +符号链接、超限或无效 TOML 使用稳定 `FMIGRATE_*` code 拒绝。写入采用同目录 staging 和 backup, +替换失败时恢复原文件,不留下半写 TOML。 + +## 0.1 隐式 Rust SDK → 显式 SDK + +早期项目没有 SDK 段,CLI 兼容解释为 `rust@0.1.0`。迁移后为: + +```toml +[sdk] +language = "rust" +version = "0.1.0" +``` + +该字段生成 manifest 的诊断性 `build.sdk/sdkVersion`,不能改变授权或 runtime 语义。显式 language +是后续 TypeScript 模板复用同一项目 schema 的前置条件,不代表 TypeScript runtime 已通过 ADR 门。 + +## 版本轴处理 + +- SDK major:必须使用对应迁移规则和 conformance suite;未知 major 拒绝自动写入。 +- Engine API:WIT 是唯一事实源;不得用源码改写绕过 host/guest major 不兼容。 +- UI API:组件和 schema 由 `floatile-ui-schema` 单源生成;major 迁移必须提供确定性源码建议。 +- manifest:作者迁移 `floatile.toml`,生成物在 `build/check` 时重新生成,禁止原地修补包。 +- 权限:迁移只能保留或收窄声明。新增/扩大权限必须由作者编辑并在安装升级时重新确认。 + +当前只有上述 0.1 项目模型迁移。未来每条规则必须同时增加 dry-run、write、幂等、失败恢复及 CLI JSON +测试,并在本文件记录来源版本、目标版本和不可自动处理项。 diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index 5c94cf2..bc75c95 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -272,6 +272,7 @@ Rust SDK 包内包含由根 `wit/floatile-widget.wit` 机械同步的发行快 `migrate` 使用版本化 JSON 报告且默认只读 dry-run;当前第一条真实迁移将旧项目隐式的 Rust SDK 选择补成 `[sdk] language/version`,为 Rust/TypeScript 共用项目模型建立显式事实。只有 `--write` 修改文件,重复执行无变化;缺失、超限、非普通文件或无效 TOML 使用稳定 `FMIGRATE_*` code 拒绝。 +版本轴与不可自动迁移边界见 [`migration-guide.md`](migration-guide.md)。 ## 8. 诊断格式 From 53d3efaf5ad9ed2f0fb6e3d7bdb6ad875d1488b7 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 13:55:06 +0800 Subject: [PATCH 05/22] feat(sdk): expose the UI registry contract Serialize the authoritative Rust UI component registry through the CLI so TypeScript code generation, editors, and agents consume component, property, binding, event, and version metadata without a parallel handwritten list. Refs: PP-M7, FR-PLUGIN-01, F11, NFR-MAINT-01 Tests: cargo fmt --all -- --check; git diff --check; RUSTC_WRAPPER= cargo test -p floatile-ui-schema registry --locked; RUSTC_WRAPPER= cargo test -p floatile-cli --test schema_cmd --locked; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked Unverified: TypeScript generator consumption --- crates/floatile-cli/src/main.rs | 31 ++++++++++---- crates/floatile-cli/tests/schema_cmd.rs | 45 +++++++++++++++++++++ crates/floatile-ui-schema/src/lib.rs | 5 ++- crates/floatile-ui-schema/src/registry.rs | 44 +++++++++++++++++--- docs/plugin-sdk/sdk-developer-experience.md | 2 + docs/plugin-sdk/ui-ir-v1.md | 4 +- 6 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 crates/floatile-cli/tests/schema_cmd.rs diff --git a/crates/floatile-cli/src/main.rs b/crates/floatile-cli/src/main.rs index b32b089..66d6429 100644 --- a/crates/floatile-cli/src/main.rs +++ b/crates/floatile-cli/src/main.rs @@ -669,18 +669,33 @@ fn print_instance_usage() { ); } -/// `floatile schema `:由单一源生成并输出 manifest.json 的 -/// 独立 JSON Schema 产物,供外部工具/编辑器校验 manifest,避免手写平行 schema。 +/// 由单一源生成 manifest schema 或 UI component registry,供 SDK/codegen 使用。 fn cmd_schema(args: &[String]) -> ExitCode { - let Some(path) = args.first().map(PathBuf::from) else { - eprintln!("用法: floatile schema "); - return ExitCode::from(2); + let (kind, path) = match args { + [path] => ("manifest", PathBuf::from(path)), + [kind, path] if matches!(kind.as_str(), "manifest" | "ui") => { + (kind.as_str(), PathBuf::from(path)) + } + _ => { + eprintln!("用法: floatile schema [manifest|ui] "); + return ExitCode::from(2); + } + }; + let text = if kind == "ui" { + serde_json::to_string_pretty(&floatile_ui_schema::component_registry_contract()) + } else { + serde_json::to_string_pretty(&floatile_core::manifest_json_schema()) + }; + let text = match text { + Ok(text) => text, + Err(_) => { + eprintln!("schema 序列化失败"); + return ExitCode::FAILURE; + } }; - let schema = floatile_core::manifest_json_schema(); - let text = serde_json::to_string_pretty(&schema).unwrap_or_else(|_| "{}".to_owned()); match std::fs::write(&path, text) { Ok(()) => { - println!("已写出 manifest JSON Schema 到 {}", path.display()); + println!("已写出 {kind} schema 到 {}", path.display()); ExitCode::SUCCESS } Err(e) => { diff --git a/crates/floatile-cli/tests/schema_cmd.rs b/crates/floatile-cli/tests/schema_cmd.rs new file mode 100644 index 0000000..f9ffa21 --- /dev/null +++ b/crates/floatile-cli/tests/schema_cmd.rs @@ -0,0 +1,45 @@ +#![allow(clippy::unwrap_used)] + +use std::process::Command; + +#[test] +fn ui_schema_command_emits_the_single_source_registry() { + let output = + std::env::temp_dir().join(format!("floatile-ui-registry-{}.json", std::process::id())); + let _ = std::fs::remove_file(&output); + let result = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args(["schema", "ui", output.to_str().unwrap()]) + .output() + .unwrap(); + assert!(result.status.success(), "stderr: {:?}", result.stderr); + let contract: serde_json::Value = + serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap(); + assert_eq!(contract["schemaVersion"], 1); + assert_eq!(contract["uiApiVersion"], floatile_ui_schema::UI_API_VERSION); + assert!( + contract["components"] + .as_array() + .unwrap() + .iter() + .any(|component| component["name"] == "Text") + ); + let _ = std::fs::remove_file(output); +} + +#[test] +fn manifest_schema_legacy_argument_remains_supported() { + let output = std::env::temp_dir().join(format!( + "floatile-manifest-schema-{}.json", + std::process::id() + )); + let _ = std::fs::remove_file(&output); + let result = Command::new(env!("CARGO_BIN_EXE_floatile")) + .args(["schema", output.to_str().unwrap()]) + .output() + .unwrap(); + assert!(result.status.success(), "stderr: {:?}", result.stderr); + let schema: serde_json::Value = + serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap(); + assert_eq!(schema["$schema"], "http://json-schema.org/draft-07/schema#"); + let _ = std::fs::remove_file(output); +} diff --git a/crates/floatile-ui-schema/src/lib.rs b/crates/floatile-ui-schema/src/lib.rs index df173b9..b731277 100644 --- a/crates/floatile-ui-schema/src/lib.rs +++ b/crates/floatile-ui-schema/src/lib.rs @@ -19,7 +19,10 @@ pub mod validate; pub use error::UiSchemaError; pub use ir::{Binding, Component, EmittedEvent, EventSchema, PropValue, StateSchema, UiDocument}; pub use patch::{MAX_PATCH_BYTES, MAX_STATE_BYTES, MAX_UPDATE_RATE_PER_SEC, merge_patch}; -pub use registry::{ChildrenPolicy, ComponentKind, ComponentSpec, JsonType, PropSchema}; +pub use registry::{ + ChildrenPolicy, ComponentKind, ComponentSpec, JsonType, PropSchema, RegistryContract, + contract as component_registry_contract, +}; pub use schema::{JsonSchema, validate_value}; pub use validate::validate_document; diff --git a/crates/floatile-ui-schema/src/registry.rs b/crates/floatile-ui-schema/src/registry.rs index e206160..7ffd833 100644 --- a/crates/floatile-ui-schema/src/registry.rs +++ b/crates/floatile-ui-schema/src/registry.rs @@ -7,11 +7,14 @@ use std::sync::LazyLock; +use serde::Serialize; + use crate::error::UiSchemaError; use crate::ir::PropValue; /// 允许的字面量 JSON 类型。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] pub enum JsonType { String, Boolean, @@ -51,7 +54,8 @@ impl JsonType { } /// 单个 prop 的 schema。 -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] pub struct PropSchema { pub name: &'static str, pub types: &'static [JsonType], @@ -63,7 +67,8 @@ pub struct PropSchema { } /// 子组件策略。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] pub enum ChildrenPolicy { Forbidden, One, @@ -71,7 +76,8 @@ pub enum ChildrenPolicy { } /// 组件类别。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] pub enum ComponentKind { Element, If, @@ -79,7 +85,8 @@ pub enum ComponentKind { } /// 组件规格。 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] pub struct ComponentSpec { pub name: &'static str, /// 首次引入该组件的 UI API 1.x minor。 @@ -167,6 +174,23 @@ pub fn components() -> &'static [ComponentSpec] { ®ISTRY } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegistryContract<'a> { + pub schema_version: u32, + pub ui_api_version: &'static str, + pub components: &'a [ComponentSpec], +} + +/// Machine-readable single-source contract consumed by SDK code generators. +pub fn contract() -> RegistryContract<'static> { + RegistryContract { + schema_version: 1, + ui_api_version: crate::UI_API_VERSION, + components: components(), + } +} + fn build_registry() -> Vec { let mut specs = Vec::new(); element(&mut specs, "Row", ChildrenPolicy::Many, &[], &[]); @@ -628,4 +652,14 @@ mod tests { assert!(column.find_prop("padding").is_some()); assert!(column.find_prop("gap").is_some()); } + + #[test] + fn machine_contract_serializes_the_registry_source() { + let value = serde_json::to_value(contract()).unwrap(); + assert_eq!(value["schemaVersion"], 1); + assert_eq!(value["uiApiVersion"], crate::UI_API_VERSION); + let specs = value["components"].as_array().unwrap(); + assert_eq!(specs.len(), components().len()); + assert!(specs.iter().any(|spec| spec["name"] == "Text")); + } } diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index bc75c95..41f70df 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -326,6 +326,8 @@ load/start/State 更新/shutdown + 宿主存活)并输出稳定 JSON。`advanc 每个模板包含简短 `AGENTS.md`,只说明项目文件、允许命令和不可编辑生成物。SDK 发布: - 组件、属性、事件、capability、错误、CLI 输出的版本化 JSON schema; +- `floatile schema ui ` 从 `floatile-ui-schema` registry 输出版本化组件/prop/event 契约, + TypeScript codegen 不维护平行组件列表; - 每个能力一个最小、可编译示例;不提供大而全 demo 作为唯一资料; - `floatile check --json --no-interactive` 的确定性输出; - `floatile preview --screenshot --ui-tree `; diff --git a/docs/plugin-sdk/ui-ir-v1.md b/docs/plugin-sdk/ui-ir-v1.md index 79e9a5d..84662f8 100644 --- a/docs/plugin-sdk/ui-ir-v1.md +++ b/docs/plugin-sdk/ui-ir-v1.md @@ -169,7 +169,9 @@ Extension: Canvas Path(通过 renderer spike 后再启用) 每个组件条目包含稳定名称、introduced minor、props schema、allowed children/slots、events、accessibility 要求和 renderer test vector。新增 optional prop/component 可以 bump minor;删除/改语义必须 bump major。 -组件 registry 是单一机器源。README、Rust/TypeScript API 或 Slint adapter 中的手写列表不是事实源。 +组件 registry 是单一机器源。`floatile schema ui ` 机械序列化同一 registry,供 +TypeScript codegen、编辑器和 Agent 消费;README、Rust/TypeScript API 或 Slint adapter 中的手写 +列表不是事实源。 ## 8. Style、theme 与 animation From 1dfb2245cceae29d678ac190ccefb5afd1031726 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:00:19 +0800 Subject: [PATCH 06/22] feat(sdk): generate TypeScript UI builders Add a private TypeScript author package whose strict component props and builders are mechanically generated from the Rust UI registry. Keep runtime selection and publication gated while CI proves stale generation, type safety, and canonical IR output. Refs: PP-M7, FR-PLUGIN-01, F11, NFR-MAINT-01 Tests: cargo fmt --all -- --check; git diff --check; RUSTC_WRAPPER= cargo test -p floatile-ui-schema registry --locked; RUSTC_WRAPPER= cargo run -p floatile-cli --locked -- schema ui target/floatile-ui-registry.json; node sdk/typescript/scripts/generate-components.mjs target/floatile-ui-registry.json sdk/typescript/src/generated/components.ts --check; pnpm --dir sdk/typescript typecheck; pnpm --dir sdk/typescript test; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked Unverified: TypeScript Component runtime and GitHub Actions execution --- .github/workflows/ci.yml | 18 ++ crates/floatile-ui-schema/src/registry.rs | 20 +- docs/architecture/workspace-and-crates.md | 2 +- docs/plugin-sdk/sdk-developer-experience.md | 3 + sdk/typescript/README.md | 16 ++ sdk/typescript/package.json | 14 + sdk/typescript/pnpm-lock.yaml | 240 ++++++++++++++++ .../scripts/generate-components.mjs | 52 ++++ sdk/typescript/src/generated/components.ts | 267 ++++++++++++++++++ sdk/typescript/src/index.ts | 2 + sdk/typescript/src/view.ts | 50 ++++ sdk/typescript/tests/view.test.ts | 21 ++ sdk/typescript/tsconfig.json | 13 + 13 files changed, 716 insertions(+), 2 deletions(-) create mode 100644 sdk/typescript/README.md create mode 100644 sdk/typescript/package.json create mode 100644 sdk/typescript/pnpm-lock.yaml create mode 100644 sdk/typescript/scripts/generate-components.mjs create mode 100644 sdk/typescript/src/generated/components.ts create mode 100644 sdk/typescript/src/index.ts create mode 100644 sdk/typescript/src/view.ts create mode 100644 sdk/typescript/tests/view.test.ts create mode 100644 sdk/typescript/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b416571..c8faeae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,24 @@ jobs: env: RUSTDOCFLAGS: -D warnings run: cargo doc -p floatile-sdk -p floatile-sdk-macros -p floatile-ui-schema --no-deps --locked + - name: Set up TypeScript SDK toolchain + if: runner.os == 'Linux' + uses: actions/setup-node@v7 + with: + node-version: '26.8.0' + - name: Set up pnpm + if: runner.os == 'Linux' + uses: pnpm/action-setup@v5 + with: + version: '11.3.0' + - name: TypeScript SDK generated contract + if: runner.os == 'Linux' + run: | + pnpm install --dir sdk/typescript --frozen-lockfile + cargo run -p floatile-cli --locked -- schema ui target/floatile-ui-registry.json + node sdk/typescript/scripts/generate-components.mjs target/floatile-ui-registry.json sdk/typescript/src/generated/components.ts --check + pnpm --dir sdk/typescript typecheck + pnpm --dir sdk/typescript test - name: Build WASI guest (hermetic integration tests) # 集成测试读取 target/wasm32-wasip2/debug 下的组件;先构建保证干净 # checkout 上测试不依赖执行顺序或测试内并发构建。 diff --git a/crates/floatile-ui-schema/src/registry.rs b/crates/floatile-ui-schema/src/registry.rs index 7ffd833..783dc83 100644 --- a/crates/floatile-ui-schema/src/registry.rs +++ b/crates/floatile-ui-schema/src/registry.rs @@ -492,7 +492,12 @@ fn element_since( events: &'static [&'static str], ) { let mut all = Vec::with_capacity(COMMON_ELEMENT_PROPS.len() + props.len()); - all.extend_from_slice(COMMON_ELEMENT_PROPS); + all.extend( + COMMON_ELEMENT_PROPS + .iter() + .filter(|common| !props.iter().any(|specific| specific.name == common.name)) + .copied(), + ); all.extend_from_slice(props); out.push(ComponentSpec { name, @@ -661,5 +666,18 @@ mod tests { let specs = value["components"].as_array().unwrap(); assert_eq!(specs.len(), components().len()); assert!(specs.iter().any(|spec| spec["name"] == "Text")); + for spec in components() { + let unique = spec + .props + .iter() + .map(|prop| prop.name) + .collect::>(); + assert_eq!( + unique.len(), + spec.props.len(), + "duplicate props in {}", + spec.name + ); + } } } diff --git a/docs/architecture/workspace-and-crates.md b/docs/architecture/workspace-and-crates.md index cd3a28a..a2acb64 100644 --- a/docs/architecture/workspace-and-crates.md +++ b/docs/architecture/workspace-and-crates.md @@ -218,7 +218,7 @@ schemas/ manifest-v1.schema.json floatile-ui-v1.schema.json sdk/ - typescript/ # @floatile/sdk;引入前需 runtime ADR + typescript/ # @floatile/sdk 构建期 UI/codegen;private,runtime 仍需后继 ADR plugins/ clock-wasm/ # Rust 参考插件 clock-typescript/ # TypeScript adapter 通过后加入 diff --git a/docs/plugin-sdk/sdk-developer-experience.md b/docs/plugin-sdk/sdk-developer-experience.md index 41f70df..bef7bb7 100644 --- a/docs/plugin-sdk/sdk-developer-experience.md +++ b/docs/plugin-sdk/sdk-developer-experience.md @@ -273,6 +273,9 @@ Rust SDK 包内包含由根 `wit/floatile-widget.wit` 机械同步的发行快 选择补成 `[sdk] language/version`,为 Rust/TypeScript 共用项目模型建立显式事实。只有 `--write` 修改文件,重复执行无变化;缺失、超限、非普通文件或无效 TOML 使用稳定 `FMIGRATE_*` code 拒绝。 版本轴与不可自动迁移边界见 [`migration-guide.md`](migration-guide.md)。 +TypeScript SDK 已建立 private 的构建期 UI 层:组件函数和 prop/binding 类型由 +`floatile schema ui` 的 registry contract 机械生成,并在 CI 中做 stale、strict typecheck 与 canonical +IR builder 测试。它尚未选择或打包 runtime,也未获准发布;生命周期 adapter 仍受第 11 节工具链门约束。 ## 8. 诊断格式 diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md new file mode 100644 index 0000000..e26d126 --- /dev/null +++ b/sdk/typescript/README.md @@ -0,0 +1,16 @@ +# `@floatile/sdk` TypeScript author surface + +> Internal PP-M7 implementation. The package remains `private` until a TypeScript Component runtime passes +> ADR, resource, isolation, cross-platform, and licensing gates. + +The build-time UI API is generated from `floatile-ui-schema` rather than a handwritten component list: + +```text +floatile schema ui target/floatile-ui-registry.json +node sdk/typescript/scripts/generate-components.mjs \ + target/floatile-ui-registry.json sdk/typescript/src/generated/components.ts --check +``` + +`pnpm typecheck` verifies generated props and author-facing bindings. `pnpm test` checks that builders emit the +same canonical `widget.ftui` component shape consumed by the Rust SDK and host validator. This package does not +select or bundle a JavaScript runtime and is not authorized for registry publication. diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json new file mode 100644 index 0000000..7befe24 --- /dev/null +++ b/sdk/typescript/package.json @@ -0,0 +1,14 @@ +{ + "name": "@floatile/sdk", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "node --test --experimental-strip-types tests/*.test.ts" + }, + "devDependencies": { + "@types/node": "24.10.1", + "typescript": "7.0.2" + } +} diff --git a/sdk/typescript/pnpm-lock.yaml b/sdk/typescript/pnpm-lock.yaml new file mode 100644 index 0000000..18b85d8 --- /dev/null +++ b/sdk/typescript/pnpm-lock.yaml @@ -0,0 +1,240 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + typescript: + specifier: 7.0.2 + version: 7.0.2 + +packages: + + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + +snapshots: + + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@7.16.0: {} diff --git a/sdk/typescript/scripts/generate-components.mjs b/sdk/typescript/scripts/generate-components.mjs new file mode 100644 index 0000000..ca1d432 --- /dev/null +++ b/sdk/typescript/scripts/generate-components.mjs @@ -0,0 +1,52 @@ +import { readFile, writeFile } from "node:fs/promises"; + +const [input, output, mode] = process.argv.slice(2); +if (!input || !output) { + throw new Error("usage: generate-components.mjs [--check]"); +} + +const registry = JSON.parse(await readFile(input, "utf8")); +if (registry.schemaVersion !== 1 || typeof registry.uiApiVersion !== "string") { + throw new Error("unsupported UI registry contract"); +} + +const scalar = (types) => [...new Set(types.map((type) => { + if (type === "string") return "string"; + if (type === "number" || type === "integer") return "number"; + if (type === "boolean") return "boolean"; + if (type === "null") return "null"; + return "JsonValue"; +}))].join(" | "); + +let source = `// Generated from floatile-ui-schema ${registry.uiApiVersion}; do not edit.\n`; +source += 'import { component } from "../view.ts";\n'; +source += 'import type { Bindable, JsonValue, View } from "../view.ts";\n\n'; +source += `export const UI_API_VERSION = ${JSON.stringify(registry.uiApiVersion)} as const;\n\n`; +for (const spec of registry.components) { + if (spec.kind !== "element") continue; + const interfaceName = `${spec.name}Props`; + source += `export interface ${interfaceName} {\n`; + for (const prop of spec.props) { + const base = scalar(prop.types); + const type = prop.allowBinding ? `Bindable<${base}>` : base; + source += ` readonly ${prop.name}${prop.optional ? "?" : ""}: ${type};\n`; + } + if (spec.children === "forbidden") { + source += " readonly children?: never;\n"; + } else if (spec.children === "one") { + source += " readonly children: View;\n"; + } else { + source += " readonly children?: View | readonly View[];\n"; + } + source += "}\n"; + source += `export function ${spec.name}(props: ${interfaceName}): View {\n`; + source += ` return component(${JSON.stringify(spec.name)}, props);\n`; + source += "}\n\n"; +} + +if (mode === "--check") { + const existing = await readFile(output, "utf8"); + if (existing !== source) throw new Error("generated TypeScript components are stale"); +} else { + await writeFile(output, source); +} diff --git a/sdk/typescript/src/generated/components.ts b/sdk/typescript/src/generated/components.ts new file mode 100644 index 0000000..f5773eb --- /dev/null +++ b/sdk/typescript/src/generated/components.ts @@ -0,0 +1,267 @@ +// Generated from floatile-ui-schema 1.6.0; do not edit. +import { component } from "../view.ts"; +import type { Bindable, JsonValue, View } from "../view.ts"; + +export const UI_API_VERSION = "1.6.0" as const; + +export interface RowProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly children?: View | readonly View[]; +} +export function Row(props: RowProps): View { + return component("Row", props); +} + +export interface ColumnProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly children?: View | readonly View[]; +} +export function Column(props: ColumnProps): View { + return component("Column", props); +} + +export interface StackProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly children?: View | readonly View[]; +} +export function Stack(props: StackProps): View { + return component("Stack", props); +} + +export interface GridProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly columns?: number; + readonly children?: View | readonly View[]; +} +export function Grid(props: GridProps): View { + return component("Grid", props); +} + +export interface ScrollProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly children?: View | readonly View[]; +} +export function Scroll(props: ScrollProps): View { + return component("Scroll", props); +} + +export interface ResponsiveProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly breakpoint: number; + readonly children?: View | readonly View[]; +} +export function Responsive(props: ResponsiveProps): View { + return component("Responsive", props); +} + +export interface TextProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly text: Bindable; + readonly style?: string; + readonly colorToken?: string; + readonly children?: never; +} +export function Text(props: TextProps): View { + return component("Text", props); +} + +export interface IconProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly name: Bindable; + readonly size?: number; + readonly children?: never; +} +export function Icon(props: IconProps): View { + return component("Icon", props); +} + +export interface ImageProps { + readonly padding?: number; + readonly gap?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly asset: string; + readonly width?: number; + readonly height?: number; + readonly children?: never; +} +export function Image(props: ImageProps): View { + return component("Image", props); +} + +export interface ButtonProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly label: Bindable; + readonly children?: never; +} +export function Button(props: ButtonProps): View { + return component("Button", props); +} + +export interface ToggleProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly checked: Bindable; + readonly accessibilityLabel?: Bindable; + readonly children?: never; +} +export function Toggle(props: ToggleProps): View { + return component("Toggle", props); +} + +export interface ProgressProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly value: Bindable; + readonly accessibilityLabel?: Bindable; + readonly children?: never; +} +export function Progress(props: ProgressProps): View { + return component("Progress", props); +} + +export interface BadgeProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly label: Bindable; + readonly tone?: string; + readonly children?: never; +} +export function Badge(props: BadgeProps): View { + return component("Badge", props); +} + +export interface GaugeProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly value: Bindable; + readonly accessibilityLabel?: Bindable; + readonly children?: never; +} +export function Gauge(props: GaugeProps): View { + return component("Gauge", props); +} + +export interface ListProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly items?: Bindable; + readonly children?: View | readonly View[]; +} +export function List(props: ListProps): View { + return component("List", props); +} + +export interface SparklineProps { + readonly padding?: number; + readonly gap?: number; + readonly width?: number; + readonly height?: number; + readonly opacity?: number; + readonly radius?: number; + readonly color?: string; + readonly border?: string; + readonly values: Bindable; + readonly label: Bindable; + readonly tone?: string; + readonly children?: never; +} +export function Sparkline(props: SparklineProps): View { + return component("Sparkline", props); +} + diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts new file mode 100644 index 0000000..6707710 --- /dev/null +++ b/sdk/typescript/src/index.ts @@ -0,0 +1,2 @@ +export * from "./view.ts"; +export * from "./generated/components.ts"; diff --git a/sdk/typescript/src/view.ts b/sdk/typescript/src/view.ts new file mode 100644 index 0000000..4565b73 --- /dev/null +++ b/sdk/typescript/src/view.ts @@ -0,0 +1,50 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; + +export interface StateBinding { + readonly bind: string; +} + +export interface ItemBinding { + readonly item: string; +} + +export type Binding = StateBinding | ItemBinding; +export type Bindable = T | Binding; + +export interface EmittedEvent { + readonly emit: string; + readonly payload?: JsonValue; +} + +export interface View { + readonly type: string; + readonly props?: Readonly>; + readonly children?: readonly View[]; + readonly events?: Readonly>; +} + +export function state(path: string): StateBinding { + if (!path.startsWith("$.")) throw new TypeError("state binding must start with $."); + return { bind: path }; +} + +export function item(field: string): ItemBinding { + if (field.length === 0) throw new TypeError("item binding must not be empty"); + return { item: field }; +} + +export function component(type: string, input: T): View { + const { children: childInput, ...props } = input as T & { readonly children?: View | readonly View[] }; + const children = childInput === undefined + ? [] + : Array.isArray(childInput) ? childInput : [childInput]; + const filtered = Object.fromEntries( + Object.entries(props).filter(([, value]) => value !== undefined), + ) as Record; + return { + type, + ...(Object.keys(filtered).length === 0 ? {} : { props: filtered }), + ...(children.length === 0 ? {} : { children }), + }; +} diff --git a/sdk/typescript/tests/view.test.ts b/sdk/typescript/tests/view.test.ts new file mode 100644 index 0000000..a2d84e6 --- /dev/null +++ b/sdk/typescript/tests/view.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Column, Text, state } from "../src/index.ts"; + +test("generated builders emit canonical Floatile UI IR", () => { + const view = Column({ + padding: 16, + gap: 8, + children: Text({ text: state("$.time"), style: "title" }), + }); + assert.deepEqual(view, { + type: "Column", + props: { padding: 16, gap: 8 }, + children: [{ type: "Text", props: { text: { bind: "$.time" }, style: "title" } }], + }); +}); + +test("state bindings reject ambiguous paths", () => { + assert.throws(() => state("time"), /must start/); +}); diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json new file mode 100644 index 0000000..0e1bc58 --- /dev/null +++ b/sdk/typescript/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "types": ["node"], + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} From 905d5cb3c3c6a2d8a940cbfc4025e599d3218ad0 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:02:04 +0800 Subject: [PATCH 07/22] feat(sdk): align TypeScript lifecycle errors Define the private TypeScript widget author surface and lower its errors to the exact WIT variants. Consume both shared lifecycle and security contracts so adapters cannot invent language-specific outcomes or leak arbitrary JavaScript exceptions. Refs: PP-M7, FR-PLUGIN-01, F11, NFR-MAINT-01 Tests: pnpm --dir sdk/typescript typecheck; pnpm --dir sdk/typescript test; cargo fmt --all -- --check; git diff --check; RUSTC_WRAPPER= cargo check --workspace --all-targets --locked Unverified: TypeScript Component runtime execution of lifecycle and security vectors --- docs/plugin-sdk/conformance-kit.md | 7 ++- sdk/typescript/src/index.ts | 1 + sdk/typescript/src/widget.ts | 57 ++++++++++++++++++++ sdk/typescript/tests/conformance.test.ts | 69 ++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 sdk/typescript/src/widget.ts create mode 100644 sdk/typescript/tests/conformance.test.ts diff --git a/docs/plugin-sdk/conformance-kit.md b/docs/plugin-sdk/conformance-kit.md index c0bbf4e..04e066a 100644 --- a/docs/plugin-sdk/conformance-kit.md +++ b/docs/plugin-sdk/conformance-kit.md @@ -1,6 +1,6 @@ # SDK Conformance Kit -> 状态:Implemented(Rust SDK 与 host runtime 自动化验证;TypeScript 尚未实现) +> 状态:Implemented(Rust SDK、TypeScript 作者层与 host runtime 自动化验证;TypeScript runtime 尚未实现) > 范围:PP-M7、FR-PLUGIN-01、F11、NFR-MAINT-01 `conformance/` 保存语言无关、版本化的 JSON 向量。Rust SDK、未来 TypeScript SDK 与宿主 runtime @@ -26,6 +26,11 @@ PP-M7 切片逐步登记为同目录的版本化向量。 新增语言 SDK 时,第一步必须解析这些文件并拒绝未知 `schemaVersion`、callback、error 或 outcome; 不能用“等价的本地测试”代替共享向量。 +private `@floatile/sdk` 作者层已直接消费 lifecycle 与 security JSON:六种 lifecycle error 映射到同一 +WIT variant,未知 JavaScript 异常只降为无 payload 的 `internal`,不会泄漏异常文本;五类 security +vector 保留为 adapter/runtime 的强制 host-survival 门。真实 TypeScript Component 执行仍未通过,不能 +把作者层解析测试等同于 runtime 证据。 + `floatile conformance --json --no-interactive` 会校验 CLI 内嵌的当前 suite 并输出稳定的 schema v1 报告;language adapter、CI 与 Agent 应消费该命令,而不是依赖仓库路径。`--deny-warnings` 与其他作者 命令语义一致;当前 suite 没有 warning。 diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 6707710..8163cf2 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1,2 +1,3 @@ export * from "./view.ts"; +export * from "./widget.ts"; export * from "./generated/components.ts"; diff --git a/sdk/typescript/src/widget.ts b/sdk/typescript/src/widget.ts new file mode 100644 index 0000000..e3d6fa7 --- /dev/null +++ b/sdk/typescript/src/widget.ts @@ -0,0 +1,57 @@ +import type { JsonValue, View } from "./view.ts"; + +export type WidgetErrorKind = "invalid-input" | "rejected" | "internal"; + +export class WidgetError extends Error { + readonly kind: WidgetErrorKind; + + private constructor(kind: WidgetErrorKind, message?: string) { + super(message ?? "internal widget error"); + this.name = "WidgetError"; + this.kind = kind; + } + + static invalidInput(message: string): WidgetError { + return new WidgetError("invalid-input", message); + } + + static rejected(message: string): WidgetError { + return new WidgetError("rejected", message); + } + + static internal(): WidgetError { + return new WidgetError("internal"); + } +} + +export type WitWidgetError = + | { readonly tag: "invalid-input"; readonly val: string } + | { readonly tag: "rejected"; readonly val: string } + | { readonly tag: "internal" }; + +export function lowerWidgetError(error: unknown): WitWidgetError { + if (!(error instanceof WidgetError)) return { tag: "internal" }; + if (error.kind === "internal") return { tag: "internal" }; + return { tag: error.kind, val: error.message }; +} + +export interface StateContext { + update(patch: Partial): void; +} + +export interface WidgetContext { + readonly state: StateContext; +} + +export interface WidgetDefinition { + readonly state: State; + readonly view: (state: Readonly) => View; + readonly start?: (context: WidgetContext) => void | Promise; + readonly event?: (event: Event, context: WidgetContext) => void | Promise; +} + +export function defineWidget( + definition: WidgetDefinition, +): WidgetDefinition { + return Object.freeze(definition); +} diff --git a/sdk/typescript/tests/conformance.test.ts b/sdk/typescript/tests/conformance.test.ts new file mode 100644 index 0000000..d6e35ab --- /dev/null +++ b/sdk/typescript/tests/conformance.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { WidgetError, lowerWidgetError } from "../src/index.ts"; + +interface LifecycleVector { + readonly id: string; + readonly callback: "start" | "event"; + readonly guestError: "invalid-input" | "rejected" | "internal"; + readonly message: string | null; + readonly expectedHostOutcome: "rejected"; +} + +interface LifecycleSuite { + readonly schemaVersion: 1; + readonly engineApiVersion: string; + readonly vectors: readonly LifecycleVector[]; +} + +const lifecycle = JSON.parse( + await readFile(new URL("../../../conformance/sdk-lifecycle-v1.json", import.meta.url), "utf8"), +) as LifecycleSuite; + +interface SecuritySuite { + readonly schemaVersion: 1; + readonly engineApiVersion: string; + readonly vectors: readonly { + readonly id: string; + readonly trigger: "start" | "event"; + readonly expectedHostOutcome: string; + readonly hostSurvives: true; + }[]; +} + +const security = JSON.parse( + await readFile(new URL("../../../conformance/sdk-security-v1.json", import.meta.url), "utf8"), +) as SecuritySuite; + +test("TypeScript errors lower to every shared lifecycle vector", () => { + assert.equal(lifecycle.schemaVersion, 1); + assert.equal(lifecycle.engineApiVersion, "1.2.0"); + assert.equal(lifecycle.vectors.length, 6); + for (const vector of lifecycle.vectors) { + const error = vector.guestError === "invalid-input" + ? WidgetError.invalidInput(vector.message ?? "") + : vector.guestError === "rejected" + ? WidgetError.rejected(vector.message ?? "") + : WidgetError.internal(); + const lowered = lowerWidgetError(error); + assert.equal(lowered.tag, vector.guestError, vector.id); + if ("val" in lowered) assert.equal(lowered.val, vector.message, vector.id); + assert.equal(vector.expectedHostOutcome, "rejected"); + } +}); + +test("unknown JavaScript exceptions do not leak through the WIT boundary", () => { + assert.deepEqual(lowerWidgetError(new Error("secret host path")), { tag: "internal" }); +}); + +test("TypeScript adapter inherits every shared host-survival requirement", () => { + assert.equal(security.schemaVersion, 1); + assert.equal(security.engineApiVersion, lifecycle.engineApiVersion); + assert.deepEqual( + security.vectors.map((vector) => vector.id), + ["broker-deny", "invalid-state-patch", "fuel-exhaustion", "wall-clock-timeout", "memory-limit"], + ); + assert.ok(security.vectors.every((vector) => vector.hostSurvives)); +}); From e009d95d057c08e39a164bd4c3448f22c4c881bf Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:25:46 +0800 Subject: [PATCH 08/22] test(runtime): revalidate the QuickJS candidate Synchronize the runtime spike with WIT 1.2 and enforce the exact nine-interface host import allowlist. Record reproducible behavior, isolation, package, and Linux resource evidence from the fixed upstream candidate without promoting it to a production dependency. Refs: PP-M7, ADR-0003, componentize-qjs#76 Tests: FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER=1 FLOATILE_COMPONENTIZE_QJS_BIN=/tmp/floatile-componentize-qjs-76/target/release/componentize-qjs pnpm --dir spikes/typescript-runtime build:quickjs Tests: FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER=1 FLOATILE_COMPONENTIZE_QJS_BIN=/tmp/floatile-componentize-qjs-76/target/release/componentize-qjs pnpm --dir spikes/typescript-runtime test:quickjs Tests: FLOATILE_TYPESCRIPT_CLOCK_WASM=target/typescript-runtime-spike/clock-typescript-quickjs.wasm cargo --config 'build.rustc-wrapper=""' test --release -p floatile-runtime --test typescript_clock_spike resource_evidence -- --ignored --nocapture --test-threads=1 Unverified: Windows and macOS adapter builds, stable-state CPU, distribution license and NOTICE inventory --- .../decisions/0003-typescript-runtime-gate.md | 14 +++++++- docs/architecture/decisions/README.md | 2 +- docs/architecture/init-plan.md | 6 ++-- spikes/typescript-runtime/README.md | 9 +++--- .../scripts/verify-component.mjs | 32 ++++++++++++++----- .../typescript-runtime/src/clock-quickjs.js | 8 ++--- 6 files changed, 51 insertions(+), 20 deletions(-) diff --git a/docs/architecture/decisions/0003-typescript-runtime-gate.md b/docs/architecture/decisions/0003-typescript-runtime-gate.md index 79946e8..03e3170 100644 --- a/docs/architecture/decisions/0003-typescript-runtime-gate.md +++ b/docs/architecture/decisions/0003-typescript-runtime-gate.md @@ -20,7 +20,7 @@ Rust 参考时钟的 `build_ftui` 生成,以免在 UI schema codegen 落地前 - `@bytecodealliance/jco` 1.31.0 + ComponentizeJS 0.22.0 + TypeScript 7.0.2。 - 支持普通 TypeScript/JavaScript;`jco guest-types` 从仓库 WIT 生成严格 guest types。 -- `jco componentize --disable all` 可移除全部 WASI feature,组件只导入七个 +- `jco componentize --disable all` 可移除全部 WASI feature,组件只导入当前 world 的九个 `floatile:widget/*` host interface。 - 官方 AOT/Weval 模式也纳入对照。 @@ -111,6 +111,18 @@ advisory 所称的 4.2.2,spike lock 以本地禁用桩移除 AOT 链;不得 - release 单实例 startup 273 ms、首 tick 1,277 ms、RSS 增量 135,327,744 B; - release 10 实例 startup 2,401 ms、全部首 tick 3,402 ms、RSS 增量 662,474,752 B。 +2026-08-28 又从 PR #76 的固定 head +`7788644697ed08a841ad0910d4e99772c6fe7132` 构建 `componentize-qjs-cli --features opt-size`,并将 +spike 同步到当前 `floatile:widget@1.2.0`/`uiApiVersion = 1.6.0`。生成组件精确白名单为九个 +`floatile:widget/*@1.2.0` host interface,零 WASI import,并重新通过四个行为/隔离测试和包预算: + +- component 1,053,110 B,package 440,106 B; +- release 单实例 startup 227 ms、首 tick 1,229 ms、RSS 增量 90,034,176 B; +- release 10 实例 startup 1,293 ms、全部首 tick 2,308 ms、RSS 增量 313,122,816 B。 + +本次数据来自同一 Linux 测试机的一次串行冷运行,保留为候选比较证据,不冒充稳定态 CPU或 +Windows/macOS 证据。固定 SHA 只用于复现未发布修复,不是 Floatile 的生产依赖。 + 这些数据显著优于本 ADR 的 StarlingMonkey 对照,但尚不改变 no-go 决策:修复未进入上游发布版, Windows/macOS 构建、稳定态 CPU、许可/NOTICE 仍未完成;QuickJS component 还额外导出 runtime 初始化函数 `init`,虽不增加 host capability,仍需在生产契约门中决定是否允许或由 adapter 隐藏。 diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 40389ec..59b67d2 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -11,7 +11,7 @@ Wayland 产品承诺、许可与分发方式。普通实现细节不需要 ADR |---|---|---| | [ADR-0001](0001-unified-plugin-ui.md) | Accepted | 插件使用统一 Floatile UI IR;Slint 仅为宿主实现,P0/MVP 不接受第三方 `.slint` | | [ADR-0002](0002-runtime-plugin-ui-rendering.md) | Implemented | 运行时第三方插件 UI 用 `slint-interpreter` 编译 renderer 生成的源码 | -| [ADR-0003](0003-typescript-runtime-gate.md) | Accepted (no-go) | 当前 StarlingMonkey 资源门失败、QuickJS 契约门失败;暂不发布 TypeScript SDK | +| [ADR-0003](0003-typescript-runtime-gate.md) | Accepted (no-go) | StarlingMonkey 资源门失败;QuickJS 未发布修复候选已过 Linux 契约门,但发布/三平台/许可门未完成 | | [ADR-0004](0004-brokered-async-operations.md) | Implemented | 长任务采用 Broker 化 Operation ID、元数据完成信号与 capability-specific typed result;v1.1 已接入首个 `storage:read` typed contract | | [ADR-0005](0005-package-signing-and-trust.md) | Accepted | detached DSSE/Ed25519 签名规范;宿主持有 publisher trust;权限授权与 anti-rollback 独立强制 | diff --git a/docs/architecture/init-plan.md b/docs/architecture/init-plan.md index 53b1056..feab4c2 100644 --- a/docs/architecture/init-plan.md +++ b/docs/architecture/init-plan.md @@ -125,8 +125,10 @@ - 先用 ADR 选择 TypeScript adapter/runtime;禁止公开非标准 TypeScript 子集或 Broker 外 ambient API。 - 与 Rust 共用 UI/component/capability/error/behavior vectors;实现 TSX 构建期 View 和同一 WIT world。 - 验收:Rust/TypeScript clocks 行为一致;单/10 实例 CPU/RSS/冷启动/包大小和三平台构建记录。 -- 当前结论:ADR-0003 已完成 Linux 候选 spike,但 StarlingMonkey 资源门失败、componentize-qjs 0.4.3 - 契约门失败,因此 **no-go**;公共 SDK/TSX/CLI 模板尚未开始,F11 不得标记完成。 +- 当前结论:ADR-0003 已完成 Linux 候选 spike;StarlingMonkey 资源门失败,componentize-qjs 0.4.3 + 契约门失败,上游未发布修复候选已通过 Linux 契约/隔离/资源复测,但三平台、许可/NOTICE 和正式 + 发布门未完成,因此仍为 **no-go**。private TypeScript SDK/UI codegen 已开始,CLI TypeScript 模板 + 与可发布 SDK 尚未启用,F11 不得标记完成。 ### S6 — `.floatile` 包 + 安装 diff --git a/spikes/typescript-runtime/README.md b/spikes/typescript-runtime/README.md index 7fafc44..ca91ca0 100644 --- a/spikes/typescript-runtime/README.md +++ b/spikes/typescript-runtime/README.md @@ -7,7 +7,7 @@ 固定工具链:Node.js ≥20、pnpm 11.3.0、`@bytecodealliance/jco` 1.31.0、 ComponentizeJS/StarlingMonkey、`componentize-qjs` 0.4.3、TypeScript 7.0.2。两个后端生成的 -组件只能导入 `floatile:widget/*`,仍由 Permission Broker 仲裁;StarlingMonkey 构建关闭全部 +组件只能导入当前 WIT world 明确列出的 `floatile:widget/*`,仍由 Permission Broker 仲裁;StarlingMonkey 构建关闭全部 WASI feature,QuickJS 构建使用 `--stub-wasi --opt-size --sync`。 Weval/AOT 已被 ADR-0003 判定体积和 RSS 更差;其 npm 链还包含未发布修复版的 `decompress` advisory,因此 lock 用本地禁用桩替换 Weval,构建脚本不提供 AOT 路径。 @@ -30,7 +30,8 @@ pnpm test:quickjs ``` 发布版 `componentize-qjs` 0.4.3 的 `test:quickjs` 预期在带参数 resource method 处失败。 -若验证上游候选修复,可在构建时设置绝对路径 +若验证上游 PR #76 固定 head +`7788644697ed08a841ad0910d4e99772c6fe7132` 的候选修复,可在从该提交构建 CLI 后设置绝对路径 `FLOATILE_COMPONENTIZE_QJS_BIN=/path/to/componentize-qjs`;输出固定为 `clock-typescript-quickjs.wasm`,同一组 Rust host tests 通过 `FLOATILE_TYPESCRIPT_CLOCK_WASM` 选择组件,不复制行为语义。 @@ -38,5 +39,5 @@ pnpm test:quickjs `pnpm measure` 在 release 下串行输出单/10 实例启动、首 tick 与 RSS(CPU 需在外层用 `time -p`/平台 profiler 采样)。最小 receiver/参数复现位于 `repro/quickjs-resource-method/`。QuickJS 对照与资源数据记录在 ADR-0003;在修复尚未发布且 -许可/三平台门未完成前结论仍为 no-go,因此公共 SDK、TSX View codegen、CLI TypeScript 模板 -仍未开始。 +许可/三平台门未完成前结论仍为 no-go。仓库中的 private TypeScript SDK/UI codegen 只用于推进 +共享契约和测试,CLI TypeScript 模板与可发布 SDK 仍不得启用。 diff --git a/spikes/typescript-runtime/scripts/verify-component.mjs b/spikes/typescript-runtime/scripts/verify-component.mjs index 34de1dd..03f56cc 100644 --- a/spikes/typescript-runtime/scripts/verify-component.mjs +++ b/spikes/typescript-runtime/scripts/verify-component.mjs @@ -29,20 +29,36 @@ const imports = [...world.matchAll(/^\s*import\s+([^;]+);$/gm)].map( const exports = [...world.matchAll(/^\s*export\s+([^;]+);$/gm)].map( (match) => match[1], ); +const widgetVersion = "1.2.0"; +const expectedImports = new Set( + [ + "host-ui", + "host-log", + "host-clock", + "host-timer", + "host-operation", + "host-storage", + "host-http", + "host-metrics", + "host-theme", + ].map((name) => `floatile:widget/${name}@${widgetVersion}`), +); const unexpected = imports.filter( - (name) => !name.startsWith("floatile:widget/"), + (name) => !expectedImports.has(name), ); if (unexpected.length > 0) { - throw new Error(`ambient imports are forbidden: ${unexpected.join(", ")}`); + throw new Error(`unexpected imports are forbidden: ${unexpected.join(", ")}`); } -if (imports.length !== 7) { - throw new Error(`expected the seven WIT host interfaces, got ${imports.length}`); +const missing = [...expectedImports].filter((name) => !imports.includes(name)); +if (missing.length > 0) { + throw new Error(`missing WIT host interfaces: ${missing.join(", ")}`); } -if (!world.includes("export floatile:widget/widget-contract@1.0.0;")) { - throw new Error("component does not export Floatile widget-contract@1.0.0"); +const widgetContract = `floatile:widget/widget-contract@${widgetVersion}`; +if (!world.includes(`export ${widgetContract};`)) { + throw new Error(`component does not export ${widgetContract}`); } const additionalExports = exports.filter( - (name) => name !== "floatile:widget/widget-contract@1.0.0", + (name) => name !== widgetContract, ); if (/^\s*import\s+wasi:/m.test(world)) { throw new Error("WASI imports are forbidden for the TypeScript adapter"); @@ -53,7 +69,7 @@ if (componentBytes > 16 * 1024 * 1024) { throw new Error(`component exceeds the 16 MiB entry limit: ${componentBytes}`); } const ui = JSON.parse(readFileSync(uiPath, "utf8")); -if (ui.uiApiVersion !== "1.0.0") { +if (ui.uiApiVersion !== "1.6.0") { throw new Error(`unexpected uiApiVersion: ${ui.uiApiVersion}`); } diff --git a/spikes/typescript-runtime/src/clock-quickjs.js b/spikes/typescript-runtime/src/clock-quickjs.js index efee6a5..9e46319 100644 --- a/spikes/typescript-runtime/src/clock-quickjs.js +++ b/spikes/typescript-runtime/src/clock-quickjs.js @@ -1,7 +1,7 @@ -import hostClock from "floatile:widget/host-clock@1.0.0"; -import hostLog from "floatile:widget/host-log@1.0.0"; -import hostTimer from "floatile:widget/host-timer@1.0.0"; -import hostUi from "floatile:widget/host-ui@1.0.0"; +import hostClock from "floatile:widget/host-clock@1.2.0"; +import hostLog from "floatile:widget/host-log@1.2.0"; +import hostTimer from "floatile:widget/host-timer@1.2.0"; +import hostUi from "floatile:widget/host-ui@1.2.0"; class WidgetInstance { constructor(init) { From 3269831d864596f5b7773b9a3f6f7127efd7092e Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:29:12 +0800 Subject: [PATCH 09/22] build(sdk): emit the TypeScript package boundary Compile the private TypeScript SDK to ESM with declarations and declaration maps, expose only dist package files, and execute contract tests against the emitted boundary. Keep the package private while adding the same build gate to CI. Refs: PP-M7 Tests: pnpm --dir sdk/typescript typecheck Tests: pnpm --dir sdk/typescript test Tests: npm pack ./sdk/typescript --dry-run --json --cache /tmp/floatile-npm-cache Unverified: registry publication remains blocked by runtime, cross-platform, and license gates --- .github/workflows/ci.yml | 1 + .gitignore | 2 +- sdk/typescript/README.md | 6 ++++-- sdk/typescript/package.json | 14 ++++++++++++-- sdk/typescript/scripts/generate-components.mjs | 4 ++-- sdk/typescript/src/generated/components.ts | 4 ++-- sdk/typescript/src/index.ts | 6 +++--- sdk/typescript/src/widget.ts | 2 +- sdk/typescript/tests/conformance.test.ts | 2 +- sdk/typescript/tests/view.test.ts | 2 +- sdk/typescript/tsconfig.build.json | 14 ++++++++++++++ 11 files changed, 42 insertions(+), 15 deletions(-) create mode 100644 sdk/typescript/tsconfig.build.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8faeae..2b50e2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,7 @@ jobs: node sdk/typescript/scripts/generate-components.mjs target/floatile-ui-registry.json sdk/typescript/src/generated/components.ts --check pnpm --dir sdk/typescript typecheck pnpm --dir sdk/typescript test + pnpm --dir sdk/typescript build - name: Build WASI guest (hermetic integration tests) # 集成测试读取 target/wasm32-wasip2/debug 下的组件;先构建保证干净 # checkout 上测试不依赖执行顺序或测试内并发构建。 diff --git a/.gitignore b/.gitignore index b5eb749..d0193d4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # 本机 cargo 配置(sccache 路径、wrapper 等本机专属,不入库) .cargo/ node_modules/ +sdk/typescript/dist/ spikes/typescript-runtime/generated/ **/*.rs.bk *.log @@ -31,4 +32,3 @@ Desktop.ini *~ *.swp *.swo - diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index e26d126..40121f8 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -12,5 +12,7 @@ node sdk/typescript/scripts/generate-components.mjs \ ``` `pnpm typecheck` verifies generated props and author-facing bindings. `pnpm test` checks that builders emit the -same canonical `widget.ftui` component shape consumed by the Rust SDK and host validator. This package does not -select or bundle a JavaScript runtime and is not authorized for registry publication. +same canonical `widget.ftui` component shape consumed by the Rust SDK and host validator. `pnpm build` emits ESM +JavaScript plus `.d.ts`/declaration maps to `dist/`; CI compiles that author-consumable boundary in addition to +checking the source. This package does not select or bundle a JavaScript runtime and is not authorized for +registry publication. diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 7befe24..9bff9a0 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -3,9 +3,19 @@ "version": "0.1.0", "private": true, "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], "scripts": { - "typecheck": "tsc --noEmit", - "test": "node --test --experimental-strip-types tests/*.test.ts" + "build": "tsc -p tsconfig.build.json", + "typecheck": "pnpm run build && tsc --noEmit", + "test": "pnpm run build && node --test --experimental-strip-types tests/*.test.ts" }, "devDependencies": { "@types/node": "24.10.1", diff --git a/sdk/typescript/scripts/generate-components.mjs b/sdk/typescript/scripts/generate-components.mjs index ca1d432..86230af 100644 --- a/sdk/typescript/scripts/generate-components.mjs +++ b/sdk/typescript/scripts/generate-components.mjs @@ -19,8 +19,8 @@ const scalar = (types) => [...new Set(types.map((type) => { }))].join(" | "); let source = `// Generated from floatile-ui-schema ${registry.uiApiVersion}; do not edit.\n`; -source += 'import { component } from "../view.ts";\n'; -source += 'import type { Bindable, JsonValue, View } from "../view.ts";\n\n'; +source += 'import { component } from "../view.js";\n'; +source += 'import type { Bindable, JsonValue, View } from "../view.js";\n\n'; source += `export const UI_API_VERSION = ${JSON.stringify(registry.uiApiVersion)} as const;\n\n`; for (const spec of registry.components) { if (spec.kind !== "element") continue; diff --git a/sdk/typescript/src/generated/components.ts b/sdk/typescript/src/generated/components.ts index f5773eb..053c9ca 100644 --- a/sdk/typescript/src/generated/components.ts +++ b/sdk/typescript/src/generated/components.ts @@ -1,6 +1,6 @@ // Generated from floatile-ui-schema 1.6.0; do not edit. -import { component } from "../view.ts"; -import type { Bindable, JsonValue, View } from "../view.ts"; +import { component } from "../view.js"; +import type { Bindable, JsonValue, View } from "../view.js"; export const UI_API_VERSION = "1.6.0" as const; diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 8163cf2..087256d 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1,3 +1,3 @@ -export * from "./view.ts"; -export * from "./widget.ts"; -export * from "./generated/components.ts"; +export * from "./view.js"; +export * from "./widget.js"; +export * from "./generated/components.js"; diff --git a/sdk/typescript/src/widget.ts b/sdk/typescript/src/widget.ts index e3d6fa7..c58ca13 100644 --- a/sdk/typescript/src/widget.ts +++ b/sdk/typescript/src/widget.ts @@ -1,4 +1,4 @@ -import type { JsonValue, View } from "./view.ts"; +import type { JsonValue, View } from "./view.js"; export type WidgetErrorKind = "invalid-input" | "rejected" | "internal"; diff --git a/sdk/typescript/tests/conformance.test.ts b/sdk/typescript/tests/conformance.test.ts index d6e35ab..4c60b55 100644 --- a/sdk/typescript/tests/conformance.test.ts +++ b/sdk/typescript/tests/conformance.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { WidgetError, lowerWidgetError } from "../src/index.ts"; +import { WidgetError, lowerWidgetError } from "../dist/index.js"; interface LifecycleVector { readonly id: string; diff --git a/sdk/typescript/tests/view.test.ts b/sdk/typescript/tests/view.test.ts index a2d84e6..d80b170 100644 --- a/sdk/typescript/tests/view.test.ts +++ b/sdk/typescript/tests/view.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { Column, Text, state } from "../src/index.ts"; +import { Column, Text, state } from "../dist/index.js"; test("generated builders emit canonical Floatile UI IR", () => { const view = Column({ diff --git a/sdk/typescript/tsconfig.build.json b/sdk/typescript/tsconfig.build.json new file mode 100644 index 0000000..886e21f --- /dev/null +++ b/sdk/typescript/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "allowImportingTsExtensions": false, + "rewriteRelativeImportExtensions": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["tests/**/*.ts"] +} From 3edb72e389e129bced371d678729b3aed31e927b Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:31:27 +0800 Subject: [PATCH 10/22] feat(sdk): adapt TypeScript widgets to WIT Add the private TypeScript lifecycle adapter that constructs the widget resource, preserves host-authoritative State updates, decodes the unified event stream, and lowers lifecycle failures without leaking unknown exceptions. Keep lifecycle callbacks synchronous because long-running work remains brokered through Operations. Refs: PP-M7, ADR-0001, ADR-0004 Tests: pnpm --dir sdk/typescript typecheck Tests: pnpm --dir sdk/typescript test Tests: npm pack ./sdk/typescript --dry-run --json --cache /tmp/floatile-npm-cache Unverified: adapter execution inside a selected production TypeScript runtime --- sdk/typescript/src/index.ts | 1 + sdk/typescript/src/runtime.ts | 100 +++++++++++++++++++++++++++ sdk/typescript/src/view.ts | 1 + sdk/typescript/src/widget.ts | 16 +++-- sdk/typescript/tests/runtime.test.ts | 66 ++++++++++++++++++ 5 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 sdk/typescript/src/runtime.ts create mode 100644 sdk/typescript/tests/runtime.test.ts diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 087256d..4b02765 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -1,3 +1,4 @@ export * from "./view.js"; export * from "./widget.js"; +export * from "./runtime.js"; export * from "./generated/components.js"; diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts new file mode 100644 index 0000000..5ebdfa1 --- /dev/null +++ b/sdk/typescript/src/runtime.ts @@ -0,0 +1,100 @@ +import type { JsonObject, JsonValue } from "./view.js"; +import { + lowerWidgetError, + type WidgetContext, + type WidgetDefinition, +} from "./widget.js"; + +export interface RuntimeWidgetInit { + readonly configJson: string; + readonly initialStateJson: string; +} + +export type RuntimeWidgetEvent = + | { readonly tag: "ui"; readonly val: { readonly name: string; readonly payloadJson: string } } + | { readonly tag: "timer"; readonly val: number } + | { readonly tag: "mode-changed"; readonly val: "edit" | "show" } + | { readonly tag: "config-changed"; readonly val: string } + | { readonly tag: "theme-changed"; readonly val: string } + | { readonly tag: "operation-completed"; readonly val: JsonValue } + | { readonly tag: "suspend" } + | { readonly tag: "resume" }; + +export interface RuntimeHost { + updateState(patchJson: string): void; +} + +export interface WidgetContractAdapter { + decodeEvent(event: RuntimeWidgetEvent): Event | undefined; +} + +export interface WidgetContractConstructor { + new(init: RuntimeWidgetInit): { + start(): void; + handleEvent(event: RuntimeWidgetEvent): void; + stop(): void; + }; +} + +export interface WidgetContract { + readonly WidgetInstance: WidgetContractConstructor; +} + +function parseState(json: string): State { + const value: unknown = JSON.parse(json); + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new TypeError("initial State must be a JSON object"); + } + return value as State; +} + +function invoke(callback: () => void): void { + try { + callback(); + } catch (error) { + throw lowerWidgetError(error); + } +} + +export function createWidgetContract( + definition: WidgetDefinition, + host: RuntimeHost, + adapter: WidgetContractAdapter, +): WidgetContract { + return { + WidgetInstance: class { + readonly #context: WidgetContext; + #state: State; + + constructor(init: RuntimeWidgetInit) { + this.#state = parseState(init.initialStateJson); + const config: unknown = JSON.parse(init.configJson); + this.#context = { + config: config as JsonValue, + state: { + update: (patch) => { + host.updateState(JSON.stringify(patch)); + this.#state = { ...this.#state, ...patch }; + }, + }, + }; + } + + start(): void { + if (definition.start) invoke(() => definition.start?.(this.#context)); + } + + handleEvent(event: RuntimeWidgetEvent): void { + if (!definition.event) return; + const decoded = adapter.decodeEvent(event); + if (decoded !== undefined) { + invoke(() => definition.event?.(decoded, this.#context)); + } + } + + stop(): void { + if (definition.stop) invoke(() => definition.stop?.(this.#context)); + } + }, + }; +} diff --git a/sdk/typescript/src/view.ts b/sdk/typescript/src/view.ts index 4565b73..39d2ce4 100644 --- a/sdk/typescript/src/view.ts +++ b/sdk/typescript/src/view.ts @@ -1,5 +1,6 @@ export type JsonPrimitive = string | number | boolean | null; export type JsonValue = JsonPrimitive | JsonValue[] | { readonly [key: string]: JsonValue }; +export type JsonObject = object; export interface StateBinding { readonly bind: string; diff --git a/sdk/typescript/src/widget.ts b/sdk/typescript/src/widget.ts index c58ca13..f5edb4c 100644 --- a/sdk/typescript/src/widget.ts +++ b/sdk/typescript/src/widget.ts @@ -1,4 +1,4 @@ -import type { JsonValue, View } from "./view.js"; +import type { JsonObject, JsonValue, View } from "./view.js"; export type WidgetErrorKind = "invalid-input" | "rejected" | "internal"; @@ -35,22 +35,24 @@ export function lowerWidgetError(error: unknown): WitWidgetError { return { tag: error.kind, val: error.message }; } -export interface StateContext { +export interface StateContext { update(patch: Partial): void; } -export interface WidgetContext { +export interface WidgetContext { readonly state: StateContext; + readonly config: JsonValue; } -export interface WidgetDefinition { +export interface WidgetDefinition { readonly state: State; readonly view: (state: Readonly) => View; - readonly start?: (context: WidgetContext) => void | Promise; - readonly event?: (event: Event, context: WidgetContext) => void | Promise; + readonly start?: (context: WidgetContext) => void; + readonly event?: (event: Event, context: WidgetContext) => void; + readonly stop?: (context: WidgetContext) => void; } -export function defineWidget( +export function defineWidget( definition: WidgetDefinition, ): WidgetDefinition { return Object.freeze(definition); diff --git a/sdk/typescript/tests/runtime.test.ts b/sdk/typescript/tests/runtime.test.ts new file mode 100644 index 0000000..25298e8 --- /dev/null +++ b/sdk/typescript/tests/runtime.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createWidgetContract, + defineWidget, + WidgetError, + type RuntimeWidgetEvent, +} from "../dist/index.js"; + +interface State { + readonly count: number; +} + +test("contract adapter commits the local State mirror only after host acceptance", () => { + const patches: string[] = []; + const widget = defineWidget({ + state: { count: 0 }, + view: () => ({ type: "Text" }), + event: (amount, context) => { + assert.deepEqual(context.config, { mode: "test" }); + context.state.update({ count: amount }); + }, + }); + const contract = createWidgetContract( + widget, + { updateState: (patch) => patches.push(patch) }, + { decodeEvent: (event) => event.tag === "timer" ? event.val : undefined }, + ); + const instance = new contract.WidgetInstance({ configJson: '{"mode":"test"}', initialStateJson: '{"count":0}' }); + instance.handleEvent({ tag: "timer", val: 2 }); + assert.deepEqual(patches, ['{"count":2}']); +}); + +test("contract adapter lowers author and unknown lifecycle errors", () => { + const event = { tag: "resume" } satisfies RuntimeWidgetEvent; + const contract = createWidgetContract( + defineWidget({ + state: { count: 0 }, + view: () => ({ type: "Text" }), + event: () => { throw WidgetError.rejected("busy"); }, + }), + { updateState: () => undefined }, + { decodeEvent: (raw) => raw }, + ); + const instance = new contract.WidgetInstance({ configJson: "{}", initialStateJson: '{"count":0}' }); + assert.throws(() => instance.handleEvent(event), (error) => { + assert.deepEqual(error, { tag: "rejected", val: "busy" }); + return true; + }); + + const unknown = createWidgetContract( + defineWidget({ + state: { count: 0 }, + view: () => ({ type: "Text" }), + start: () => { throw new Error("secret"); }, + }), + { updateState: () => undefined }, + { decodeEvent: () => undefined }, + ); + const unknownInstance = new unknown.WidgetInstance({ configJson: "{}", initialStateJson: '{"count":0}' }); + assert.throws(() => unknownInstance.start(), (error) => { + assert.deepEqual(error, { tag: "internal" }); + return true; + }); +}); From 5385b0a5fe285f3f246e75903ffdbd8bd61e7fa1 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:36:01 +0800 Subject: [PATCH 11/22] test(sdk): run TypeScript through the host Move the QuickJS reference clock onto the private TypeScript SDK lifecycle adapter and drive all six shared lifecycle error vectors through the generated component and Wasmtime host. Preserve the behavior, Broker denial, timeout, memory isolation, package budget, and StarlingMonkey comparison paths on WIT 1.2. Refs: PP-M7, ADR-0003, componentize-qjs#76 Tests: FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER=1 FLOATILE_COMPONENTIZE_QJS_BIN=/tmp/floatile-componentize-qjs-76/target/release/componentize-qjs pnpm --dir spikes/typescript-runtime build:quickjs Tests: FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER=1 FLOATILE_COMPONENTIZE_QJS_BIN=/tmp/floatile-componentize-qjs-76/target/release/componentize-qjs pnpm --dir spikes/typescript-runtime test:quickjs Tests: FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER=1 pnpm --dir spikes/typescript-runtime build:starlingmonkey Unverified: Windows and macOS adapter builds, stable-state CPU, distribution license and NOTICE inventory --- .../tests/typescript_clock_spike.rs | 79 ++++++++++++++++++- .../decisions/0003-typescript-runtime-gate.md | 5 ++ spikes/typescript-runtime/README.md | 2 + .../scripts/build-backend.mjs | 9 ++- .../typescript-runtime/src/clock-quickjs.js | 60 +++++++++----- spikes/typescript-runtime/src/clock.ts | 10 +-- 6 files changed, 139 insertions(+), 26 deletions(-) diff --git a/crates/floatile-runtime/tests/typescript_clock_spike.rs b/crates/floatile-runtime/tests/typescript_clock_spike.rs index 598ee26..57cd6a5 100644 --- a/crates/floatile-runtime/tests/typescript_clock_spike.rs +++ b/crates/floatile-runtime/tests/typescript_clock_spike.rs @@ -12,13 +12,39 @@ use floatile_core::capability::{ narrow_instance, }; use floatile_core::types::{InstanceId, PluginId}; +use floatile_plugin_api::exports::floatile::widget::widget_contract::{UiEvent, WidgetEvent}; use floatile_runtime::harness::WidgetHarness; -use floatile_runtime::{WidgetConfig, WidgetManager}; +use floatile_runtime::{InstanceError, WidgetConfig, WidgetManager}; use serde_json::json; #[path = "support/clock_behavior.rs"] mod clock_behavior; +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct LifecycleSuite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct LifecycleVector { + id: String, + callback: String, + message: Option, + expected_host_outcome: String, +} + +fn lifecycle_suite() -> LifecycleSuite { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-lifecycle-v1.json" + ))) + .expect("lifecycle conformance vectors must parse") +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -167,6 +193,57 @@ async fn typescript_clock_denied_timer_is_brokered_and_instance_survives() { clock_behavior::assert_timer_denied(harness(false)).await; } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires spikes/typescript-runtime pnpm build"] +async fn typescript_clock_lifecycle_errors_match_shared_vectors() { + let wasm = component_bytes(); + let manager = WidgetManager::new().expect("创建共享 engine"); + let suite = lifecycle_suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!( + suite.engine_api_version, + floatile_plugin_api::ENGINE_API_VERSION + ); + + for (index, vector) in suite.vectors.iter().enumerate() { + let instance = InstanceId(201 + u64::try_from(index).expect("bounded vector index")); + let handle = manager + .spawn(widget_config( + instance, + wasm.clone(), + &serde_json::json!({"mode": format!("conformance-{}", vector.id)}).to_string(), + )) + .expect("TypeScript conformance fixture spawn"); + let result = if vector.callback == "start" { + handle.start().await + } else { + handle.start().await.expect("event vector should start"); + handle + .handle_event(WidgetEvent::Ui(UiEvent { + name: "trigger".into(), + payload_json: "{}".into(), + })) + .await + }; + assert_eq!(vector.expected_host_outcome, "rejected"); + assert!( + matches!(result, Err(InstanceError::Rejected(ref message)) + if vector.message.as_ref().is_none_or(|expected| message.contains(expected))), + "TypeScript conformance vector {} should remain a guest rejection, got {result:?}", + vector.id + ); + } + + let survivor = manager + .spawn(widget_config(InstanceId(209), wasm, "{}")) + .expect("survivor spawn"); + survivor + .start() + .await + .expect("host survives TypeScript rejection"); + survivor.shutdown().await.expect("survivor should stop"); +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires spikes/typescript-runtime pnpm build"] async fn typescript_clock_timeout_isolated_from_peer() { diff --git a/docs/architecture/decisions/0003-typescript-runtime-gate.md b/docs/architecture/decisions/0003-typescript-runtime-gate.md index 03e3170..924d392 100644 --- a/docs/architecture/decisions/0003-typescript-runtime-gate.md +++ b/docs/architecture/decisions/0003-typescript-runtime-gate.md @@ -120,6 +120,11 @@ spike 同步到当前 `floatile:widget@1.2.0`/`uiApiVersion = 1.6.0`。生成组 - release 单实例 startup 227 ms、首 tick 1,229 ms、RSS 增量 90,034,176 B; - release 10 实例 startup 1,293 ms、全部首 tick 2,308 ms、RSS 增量 313,122,816 B。 +随后将 QuickJS 参考时钟改为消费 private `sdk/typescript` 的 +`defineWidget`/`createWidgetContract`,不再手写 resource export glue。组件 1,080,365 B、package +453,211 B,六条共享 lifecycle error 向量均由真实 SDK → adapter → Wasmtime 链路识别为 guest +rejection,既有行为、Broker deny、timeout、低内存与 peer 存活测试继续通过。 + 本次数据来自同一 Linux 测试机的一次串行冷运行,保留为候选比较证据,不冒充稳定态 CPU或 Windows/macOS 证据。固定 SHA 只用于复现未发布修复,不是 Floatile 的生产依赖。 diff --git a/spikes/typescript-runtime/README.md b/spikes/typescript-runtime/README.md index ca91ca0..4158cdd 100644 --- a/spikes/typescript-runtime/README.md +++ b/spikes/typescript-runtime/README.md @@ -35,6 +35,8 @@ pnpm test:quickjs `FLOATILE_COMPONENTIZE_QJS_BIN=/path/to/componentize-qjs`;输出固定为 `clock-typescript-quickjs.wasm`,同一组 Rust host tests 通过 `FLOATILE_TYPESCRIPT_CLOCK_WASM` 选择组件,不复制行为语义。 +QuickJS 参考时钟消费 `sdk/typescript` 构建产物的 `defineWidget`/`createWidgetContract`,六条共享 +lifecycle error 向量会真实穿过 SDK、adapter 和 Wasmtime host;它不是只做 Node 对象形状测试。 `pnpm measure` 在 release 下串行输出单/10 实例启动、首 tick 与 RSS(CPU 需在外层用 `time -p`/平台 profiler 采样)。最小 receiver/参数复现位于 diff --git a/spikes/typescript-runtime/scripts/build-backend.mjs b/spikes/typescript-runtime/scripts/build-backend.mjs index 1450d24..d03fb07 100644 --- a/spikes/typescript-runtime/scripts/build-backend.mjs +++ b/spikes/typescript-runtime/scripts/build-backend.mjs @@ -19,6 +19,11 @@ const executable = (name) => process.platform === "win32" ? `${name}.CMD` : name, ); +execFileSync("pnpm", ["--dir", "sdk/typescript", "build"], { + cwd: workspace, + stdio: "inherit", +}); + execFileSync(process.execPath, ["scripts/prepare.mjs"], { cwd: spikeDir, stdio: "inherit", @@ -68,7 +73,7 @@ if (backend === "starlingmonkey") { "--js", resolve(spikeDir, "src/clock-quickjs.js"), "--module-root", - spikeDir, + workspace, "--output", output, "--world", @@ -86,7 +91,7 @@ if (backend === "starlingmonkey") { witPath: resolve(workspace, "wit"), jsSource: readFileSync(sourcePath, "utf8"), jsPath: sourcePath, - moduleRoot: spikeDir, + moduleRoot: workspace, world: "floatile-widget", stubWasi: true, optSize: true, diff --git a/spikes/typescript-runtime/src/clock-quickjs.js b/spikes/typescript-runtime/src/clock-quickjs.js index 9e46319..2773991 100644 --- a/spikes/typescript-runtime/src/clock-quickjs.js +++ b/spikes/typescript-runtime/src/clock-quickjs.js @@ -3,19 +3,37 @@ import hostLog from "floatile:widget/host-log@1.2.0"; import hostTimer from "floatile:widget/host-timer@1.2.0"; import hostUi from "floatile:widget/host-ui@1.2.0"; -class WidgetInstance { - constructor(init) { - // constructor 只建立内存状态,不调用任何 host capability。 - this.initialState = JSON.parse(init.initialStateJson); - const config = JSON.parse(init.configJson); - this.mode = typeof config.mode === "string" ? config.mode : undefined; +import { + createWidgetContract, + defineWidget, + WidgetError, +} from "../../../sdk/typescript/dist/index.js"; + +function conformanceError(config, callback) { + const mode = config?.mode; + if (mode === `conformance-${callback}-invalid-input`) { + return WidgetError.invalidInput(`conformance ${callback} invalid input`); + } + if (mode === `conformance-${callback}-rejected`) { + return WidgetError.rejected(`conformance ${callback} rejection`); + } + if (mode === `conformance-${callback}-internal`) { + return WidgetError.internal(); } + return undefined; +} + +const clock = defineWidget({ + state: { running: false, time: "" }, + view: () => ({ type: "Text" }), - start() { - if (this.mode === "loop") { - // 安全向量:验证同一 JS runtime 仍受 Wasmtime fuel/epoch 预算约束。 + start(context) { + const error = conformanceError(context.config, "start"); + if (error) throw error; + const mode = context.config?.mode; + if (mode === "loop") { while (true) { - this.initialState; + context.config; } } try { @@ -29,12 +47,14 @@ class WidgetInstance { } catch { // Permission Broker 的 deny 仍由宿主审计。 } - } + }, - handleEvent(event) { + event(event, context) { + const error = conformanceError(context.config, "event"); + if (error) throw error; if (event.tag === "ui" && event.val.name === "start") { try { - hostUi.updateState(JSON.stringify({ running: true })); + context.state.update({ running: true }); } catch { // State 权威校验在宿主;拒绝不回写本地镜像。 } @@ -50,7 +70,7 @@ class WidgetInstance { const time = `${pad(hour)}:${pad(minute)}:${pad(second)}`; try { - hostUi.updateState(JSON.stringify({ time })); + context.state.update({ time }); } catch { // 保持与 Rust clock 的 best-effort State Patch 语义一致。 } @@ -65,7 +85,7 @@ class WidgetInstance { // 一次性 timer 重新调度失败时停止 tick,但实例存活。 } } - } + }, stop() { try { @@ -73,7 +93,11 @@ class WidgetInstance { } catch { // stop 是有预算的尽力清理通知。 } - } -} + }, +}); -export const widgetContract = { WidgetInstance }; +export const widgetContract = createWidgetContract( + clock, + { updateState: (patchJson) => hostUi.updateState(patchJson) }, + { decodeEvent: (event) => event }, +); diff --git a/spikes/typescript-runtime/src/clock.ts b/spikes/typescript-runtime/src/clock.ts index f7f25bc..11cdb61 100644 --- a/spikes/typescript-runtime/src/clock.ts +++ b/spikes/typescript-runtime/src/clock.ts @@ -1,11 +1,11 @@ -import { now } from "floatile:widget/host-clock@1.0.0"; -import { log } from "floatile:widget/host-log@1.0.0"; -import { schedule } from "floatile:widget/host-timer@1.0.0"; -import { updateState } from "floatile:widget/host-ui@1.0.0"; +import { now } from "floatile:widget/host-clock@1.2.0"; +import { log } from "floatile:widget/host-log@1.2.0"; +import { schedule } from "floatile:widget/host-timer@1.2.0"; +import { updateState } from "floatile:widget/host-ui@1.2.0"; import type { WidgetEvent, WidgetInit, -} from "floatile:widget/widget-contract@1.0.0"; +} from "floatile:widget/widget-contract@1.2.0"; class WidgetInstance { private readonly initialState: unknown; From 8409ae7bece50907d81edc3fab1ef627b0f03b5d Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:40:20 +0800 Subject: [PATCH 12/22] test(sdk): execute shared TypeScript security vectors Drive broker denial, invalid State Patch, fuel exhaustion, epoch timeout, and StoreLimits isolation from the shared security suite through the TypeScript component. Distinguish catchable JavaScript allocation errors from Wasmtime linear-memory failures and retain peer-survival assertions. Refs: PP-M7, ADR-0003 Tests: FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER=1 FLOATILE_COMPONENTIZE_QJS_BIN=/tmp/floatile-componentize-qjs-76/target/release/componentize-qjs pnpm --dir spikes/typescript-runtime build:quickjs Tests: FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER=1 FLOATILE_COMPONENTIZE_QJS_BIN=/tmp/floatile-componentize-qjs-76/target/release/componentize-qjs pnpm --dir spikes/typescript-runtime test:quickjs Unverified: Windows and macOS adapter builds, stable-state CPU, distribution license and NOTICE inventory --- .../tests/typescript_clock_spike.rs | 157 +++++++++++++++++- .../decisions/0003-typescript-runtime-gate.md | 7 +- spikes/typescript-runtime/README.md | 3 + .../typescript-runtime/src/clock-quickjs.js | 14 +- 4 files changed, 174 insertions(+), 7 deletions(-) diff --git a/crates/floatile-runtime/tests/typescript_clock_spike.rs b/crates/floatile-runtime/tests/typescript_clock_spike.rs index 57cd6a5..daef228 100644 --- a/crates/floatile-runtime/tests/typescript_clock_spike.rs +++ b/crates/floatile-runtime/tests/typescript_clock_spike.rs @@ -37,6 +37,24 @@ struct LifecycleVector { expected_host_outcome: String, } +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecuritySuite { + schema_version: u64, + engine_api_version: String, + vectors: Vec, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SecurityVector { + id: String, + mode: String, + trigger: String, + expected_host_outcome: String, + host_survives: bool, +} + fn lifecycle_suite() -> LifecycleSuite { serde_json::from_str(include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -45,6 +63,22 @@ fn lifecycle_suite() -> LifecycleSuite { .expect("lifecycle conformance vectors must parse") } +fn security_suite() -> SecuritySuite { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../conformance/sdk-security-v1.json" + ))) + .expect("security conformance vectors must parse") +} + +fn security_vector<'a>(suite: &'a SecuritySuite, id: &str) -> &'a SecurityVector { + suite + .vectors + .iter() + .find(|vector| vector.id == id) + .unwrap_or_else(|| panic!("missing security vector {id}")) +} + fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -190,9 +224,55 @@ async fn typescript_clock_matches_reference_behavior() { #[tokio::test(flavor = "multi_thread")] #[ignore = "requires spikes/typescript-runtime pnpm build"] async fn typescript_clock_denied_timer_is_brokered_and_instance_survives() { + let suite = security_suite(); + let vector = security_vector(&suite, "broker-deny"); + assert_eq!(vector.mode, "deny"); + assert_eq!(vector.trigger, "start"); + assert_eq!(vector.expected_host_outcome, "completed"); + assert!(vector.host_survives); clock_behavior::assert_timer_denied(harness(false)).await; } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires spikes/typescript-runtime pnpm build"] +async fn typescript_clock_invalid_patch_matches_shared_vector() { + let suite = security_suite(); + assert_eq!(suite.schema_version, 1); + assert_eq!( + suite.engine_api_version, + floatile_plugin_api::ENGINE_API_VERSION + ); + let vector = security_vector(&suite, "invalid-state-patch"); + assert_eq!(vector.trigger, "start"); + assert_eq!( + vector.expected_host_outcome, + "completed-without-state-update" + ); + assert!(vector.host_survives); + + let manager = WidgetManager::new().expect("创建共享 engine"); + let mut handle = manager + .spawn(widget_config( + InstanceId(211), + component_bytes(), + &serde_json::json!({"mode": vector.mode}).to_string(), + )) + .expect("bad patch fixture spawn"); + handle + .start() + .await + .expect("bad patch is a recoverable host rejection"); + let update = tokio::time::timeout(Duration::from_millis(300), handle.ui_updates().recv()).await; + assert!( + update.is_err(), + "rejected patch must not publish State: {update:?}" + ); + handle + .shutdown() + .await + .expect("bad patch instance should stop"); +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires spikes/typescript-runtime pnpm build"] async fn typescript_clock_lifecycle_errors_match_shared_vectors() { @@ -246,29 +326,100 @@ async fn typescript_clock_lifecycle_errors_match_shared_vectors() { #[tokio::test(flavor = "multi_thread")] #[ignore = "requires spikes/typescript-runtime pnpm build"] -async fn typescript_clock_timeout_isolated_from_peer() { +async fn typescript_clock_fuel_exhaustion_matches_shared_vector() { + let suite = security_suite(); + let vector = security_vector(&suite, "fuel-exhaustion"); + assert_eq!(vector.trigger, "event"); + assert_eq!(vector.expected_host_outcome, "failed"); + assert!(vector.host_survives); let wasm = component_bytes(); let manager = WidgetManager::new().expect("创建共享 engine"); let looping = manager .spawn(widget_config( InstanceId(101), wasm.clone(), - r#"{"mode":"loop"}"#, + &serde_json::json!({"mode": vector.mode}).to_string(), )) .expect("loop fixture spawn"); let peer = manager .spawn(widget_config(InstanceId(102), wasm, "{}")) .expect("peer spawn"); - assert!(looping.start().await.is_err(), "无限循环必须被预算终止"); + looping + .start() + .await + .expect("event loop fixture should start"); + let result = looping + .handle_event(WidgetEvent::Ui(UiEvent { + name: "trigger".into(), + payload_json: "{}".into(), + })) + .await; + assert!( + matches!(result, Err(InstanceError::Failed(_))), + "无限循环必须耗尽 fuel: {result:?}" + ); peer.start().await.expect("peer start 应成功"); peer.shutdown().await.expect("peer shutdown 应成功"); } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires spikes/typescript-runtime pnpm build"] +async fn typescript_clock_wall_timeout_matches_shared_vector() { + let suite = security_suite(); + let vector = security_vector(&suite, "wall-clock-timeout"); + assert_eq!(vector.trigger, "event"); + assert_eq!(vector.expected_host_outcome, "timed-out"); + assert!(vector.host_survives); + + let wasm = component_bytes(); + let manager = WidgetManager::new() + .expect("创建共享 engine") + .with_fuel_per_call(u64::MAX) + .with_call_timeout(Duration::from_millis(25)); + let looping = manager + .spawn(widget_config( + InstanceId(221), + wasm.clone(), + &serde_json::json!({"mode": vector.mode}).to_string(), + )) + .expect("wall timeout fixture spawn"); + let peer = manager + .spawn(widget_config(InstanceId(222), wasm, "{}")) + .expect("peer spawn"); + looping + .start() + .await + .expect("event loop fixture should start"); + peer.start().await.expect("peer start should succeed"); + let result = tokio::time::timeout( + Duration::from_secs(1), + looping.handle_event(WidgetEvent::Ui(UiEvent { + name: "trigger".into(), + payload_json: "{}".into(), + })), + ) + .await + .expect("epoch deadline should beat the host fallback"); + assert!( + matches!(result, Err(InstanceError::Failed(_))), + "墙钟预算必须终止实例: {result:?}" + ); + peer.shutdown() + .await + .expect("peer should survive wall timeout"); +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires spikes/typescript-runtime pnpm build"] async fn typescript_clock_memory_limit_isolated_from_peer() { + let suite = security_suite(); + let vector = security_vector(&suite, "memory-limit"); + assert_eq!(vector.trigger, "start"); + assert_eq!(vector.expected_host_outcome, "failed"); + assert!(vector.host_survives); let constrained = harness_builder(true) + .config_json(serde_json::json!({"mode": vector.mode}).to_string()) .max_memory(1024 * 1024) .build() .expect("低内存 fixture spawn"); diff --git a/docs/architecture/decisions/0003-typescript-runtime-gate.md b/docs/architecture/decisions/0003-typescript-runtime-gate.md index 924d392..877037d 100644 --- a/docs/architecture/decisions/0003-typescript-runtime-gate.md +++ b/docs/architecture/decisions/0003-typescript-runtime-gate.md @@ -121,9 +121,10 @@ spike 同步到当前 `floatile:widget@1.2.0`/`uiApiVersion = 1.6.0`。生成组 - release 10 实例 startup 1,293 ms、全部首 tick 2,308 ms、RSS 增量 313,122,816 B。 随后将 QuickJS 参考时钟改为消费 private `sdk/typescript` 的 -`defineWidget`/`createWidgetContract`,不再手写 resource export glue。组件 1,080,365 B、package -453,211 B,六条共享 lifecycle error 向量均由真实 SDK → adapter → Wasmtime 链路识别为 guest -rejection,既有行为、Broker deny、timeout、低内存与 peer 存活测试继续通过。 +`defineWidget`/`createWidgetContract`,不再手写 resource export glue。组件 1,081,238 B、package +453,512 B,六条共享 lifecycle error 向量均由真实 SDK → adapter → Wasmtime 链路识别为 guest +rejection;共享安全向量的 Broker deny、invalid patch、fuel、墙钟预算、StoreLimits 与 peer 存活也由 +同一 TypeScript component 实测通过。 本次数据来自同一 Linux 测试机的一次串行冷运行,保留为候选比较证据,不冒充稳定态 CPU或 Windows/macOS 证据。固定 SHA 只用于复现未发布修复,不是 Floatile 的生产依赖。 diff --git a/spikes/typescript-runtime/README.md b/spikes/typescript-runtime/README.md index 4158cdd..4e1e6ac 100644 --- a/spikes/typescript-runtime/README.md +++ b/spikes/typescript-runtime/README.md @@ -37,6 +37,9 @@ pnpm test:quickjs `FLOATILE_TYPESCRIPT_CLOCK_WASM` 选择组件,不复制行为语义。 QuickJS 参考时钟消费 `sdk/typescript` 构建产物的 `defineWidget`/`createWidgetContract`,六条共享 lifecycle error 向量会真实穿过 SDK、adapter 和 Wasmtime host;它不是只做 Node 对象形状测试。 +同一 host suite 还从 `sdk-security-v1.json` 驱动 Broker deny、invalid patch、fuel exhaustion、epoch +墙钟预算和 StoreLimits/peer 隔离。普通 JavaScript 堆分配失败是可捕获异常,不冒充 Wasmtime +线性内存 trap;内存向量由宿主 StoreLimits 验证。 `pnpm measure` 在 release 下串行输出单/10 实例启动、首 tick 与 RSS(CPU 需在外层用 `time -p`/平台 profiler 采样)。最小 receiver/参数复现位于 diff --git a/spikes/typescript-runtime/src/clock-quickjs.js b/spikes/typescript-runtime/src/clock-quickjs.js index 2773991..1ec348c 100644 --- a/spikes/typescript-runtime/src/clock-quickjs.js +++ b/spikes/typescript-runtime/src/clock-quickjs.js @@ -31,11 +31,18 @@ const clock = defineWidget({ const error = conformanceError(context.config, "start"); if (error) throw error; const mode = context.config?.mode; - if (mode === "loop") { + if (mode === "loop-start") { while (true) { context.config; } } + if (mode === "bad-patch") { + try { + context.state.update({ unexpected: true }); + } catch { + // 宿主拒绝后 SDK 不提交本地 State mirror。 + } + } try { hostLog.log("info", "clock-typescript started"); } catch { @@ -52,6 +59,11 @@ const clock = defineWidget({ event(event, context) { const error = conformanceError(context.config, "event"); if (error) throw error; + if (context.config?.mode === "loop") { + while (true) { + event; + } + } if (event.tag === "ui" && event.val.name === "start") { try { context.state.update({ running: true }); From a73b7b086e18ff9438847cdb1271222d01e8ed4b Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:49:43 +0800 Subject: [PATCH 13/22] fix(platform): aggregate Linux process CPU Sum schedstat CPU time across every live thread under /proc/self/task so process metrics include Tokio and Wasmtime workers instead of reporting only the caller thread. Tolerate threads that exit during sampling and cover the behavior with active worker threads. Refs: PP-M7, NFR-PERF-01 Tests: cargo --config 'build.rustc-wrapper=""' test -p floatile-platform metrics --locked Tests: cargo --config 'build.rustc-wrapper=""' clippy -p floatile-platform --all-targets --locked -- -D warnings Unverified: Linux kernels without procfs --- crates/floatile-platform/src/metrics.rs | 76 +++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/crates/floatile-platform/src/metrics.rs b/crates/floatile-platform/src/metrics.rs index cff3b09..9ad8293 100644 --- a/crates/floatile-platform/src/metrics.rs +++ b/crates/floatile-platform/src/metrics.rs @@ -92,21 +92,52 @@ fn macos_process_metrics() -> Result { #[cfg(target_os = "linux")] fn linux_process_metrics() -> Result { - const SCHEDSTAT: &str = "/proc/self/schedstat"; + const TASKS: &str = "/proc/self/task"; + const TASK_SCHEDSTAT: &str = "/proc/self/task/*/schedstat"; const STATUS: &str = "/proc/self/status"; - let schedstat = std::fs::read_to_string(SCHEDSTAT).map_err(|source| MetricsError::Read { - source_name: SCHEDSTAT, + let tasks = std::fs::read_dir(TASKS).map_err(|source| MetricsError::Read { + source_name: TASKS, source, })?; + let mut cpu_nanoseconds = 0u64; + let mut sampled_threads = 0usize; + for task in tasks { + let task = task.map_err(|source| MetricsError::Read { + source_name: TASKS, + source, + })?; + let path = task.path().join("schedstat"); + let schedstat = match std::fs::read_to_string(path) { + Ok(value) => value, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => continue, + Err(source) => { + return Err(MetricsError::Read { + source_name: TASK_SCHEDSTAT, + source, + }); + } + }; + let thread_cpu = parse_cpu_nanoseconds(&schedstat).ok_or(MetricsError::Invalid { + source_name: TASK_SCHEDSTAT, + })?; + cpu_nanoseconds = cpu_nanoseconds + .checked_add(thread_cpu) + .ok_or(MetricsError::Invalid { + source_name: TASK_SCHEDSTAT, + })?; + sampled_threads += 1; + } + if sampled_threads == 0 { + return Err(MetricsError::Invalid { + source_name: TASK_SCHEDSTAT, + }); + } let status = std::fs::read_to_string(STATUS).map_err(|source| MetricsError::Read { source_name: STATUS, source, })?; - let cpu_nanoseconds = parse_cpu_nanoseconds(&schedstat).ok_or(MetricsError::Invalid { - source_name: SCHEDSTAT, - })?; let rss_bytes = parse_rss_bytes(&status).ok_or(MetricsError::Invalid { source_name: STATUS, })?; @@ -134,6 +165,8 @@ fn parse_rss_bytes(status: &str) -> Option { #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Barrier}; #[test] fn parses_schedstat_cpu_nanoseconds() { @@ -141,6 +174,37 @@ mod tests { assert_eq!(parse_cpu_nanoseconds("invalid 789 10\n"), None); } + #[test] + #[allow(clippy::expect_used)] + fn process_cpu_includes_worker_threads() { + let barrier = Arc::new(Barrier::new(3)); + let stop = Arc::new(AtomicBool::new(false)); + let workers: Vec<_> = (0..2) + .map(|_| { + let barrier = Arc::clone(&barrier); + let stop = Arc::clone(&stop); + std::thread::spawn(move || { + barrier.wait(); + while !stop.load(Ordering::Relaxed) { + std::hint::spin_loop(); + } + }) + }) + .collect(); + barrier.wait(); + let before = process_metrics().expect("process metrics before worker load"); + std::thread::sleep(Duration::from_millis(100)); + let after = process_metrics().expect("process metrics after worker load"); + stop.store(true, Ordering::Relaxed); + for worker in workers { + worker.join().expect("worker should stop"); + } + assert!( + after.cpu_time > before.cpu_time, + "worker CPU must be included in process metrics" + ); + } + #[test] fn parses_status_rss_kib_as_bytes() { let status = "Name:\tfloatile-shell\nVmRSS:\t 219240 kB\nThreads:\t2\n"; From ee52fd88cfe37b19ef9e2f5f0526b24666b28f42 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:53:46 +0800 Subject: [PATCH 14/22] test(runtime): record TypeScript steady-state CPU Extend the release resource evidence with a three-second post-first-tick CPU window for one and ten TypeScript instances. Record the corrected Linux results and separate the embedded runtime license closure from the still-failing CLI build and NOTICE gates. Refs: PP-M7, ADR-0003, NFR-PERF-01 Tests: FLOATILE_TYPESCRIPT_CLOCK_WASM=target/typescript-runtime-spike/clock-typescript-quickjs.wasm cargo --config 'build.rustc-wrapper=""' test --release -p floatile-runtime --test typescript_clock_spike resource_evidence -- --ignored --nocapture --test-threads=1 Unverified: Windows and macOS build/resource evidence; automated distribution NOTICE; full CLI license allowlist --- .../tests/typescript_clock_spike.rs | 16 ++++++++++++-- .../decisions/0003-typescript-runtime-gate.md | 22 +++++++++++++++---- spikes/typescript-runtime/README.md | 5 +++-- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/crates/floatile-runtime/tests/typescript_clock_spike.rs b/crates/floatile-runtime/tests/typescript_clock_spike.rs index daef228..5eb7420 100644 --- a/crates/floatile-runtime/tests/typescript_clock_spike.rs +++ b/crates/floatile-runtime/tests/typescript_clock_spike.rs @@ -197,6 +197,10 @@ async fn measure_instances(count: u64) { .expect("4 秒内应收到每个实例的首个 tick"); } let first_tick = started.elapsed(); + let stable_started = Instant::now(); + let stable_before = floatile_platform::process_metrics().ok(); + tokio::time::sleep(Duration::from_secs(3)).await; + let stable_elapsed = stable_started.elapsed(); let measured = floatile_platform::process_metrics().ok(); while let Some(handle) = handles.pop() { handle.shutdown().await.expect("shutdown TypeScript clock"); @@ -205,13 +209,21 @@ async fn measure_instances(count: u64) { let rss_delta_bytes = baseline .zip(measured) .map(|(before, after)| after.rss_bytes.saturating_sub(before.rss_bytes)); + let stable_cpu_delta = stable_before + .zip(measured) + .map(|(before, after)| after.cpu_time.saturating_sub(before.cpu_time)); + let stable_cpu_percent = + stable_cpu_delta.map(|cpu| 100.0 * cpu.as_secs_f64() / stable_elapsed.as_secs_f64()); println!( - "{{\"instances\":{count},\"componentBytes\":{},\"startupMs\":{},\"allFirstTicksMs\":{},\"rssBytes\":{},\"rssDeltaBytes\":{}}}", + "{{\"instances\":{count},\"componentBytes\":{},\"startupMs\":{},\"allFirstTicksMs\":{},\"rssBytes\":{},\"rssDeltaBytes\":{},\"stableSampleMs\":{},\"stableCpuMs\":{},\"stableCpuPercentOneCore\":{}}}", wasm.len(), startup.as_millis(), first_tick.as_millis(), rss_bytes.map_or_else(|| "null".into(), |value| value.to_string()), - rss_delta_bytes.map_or_else(|| "null".into(), |value| value.to_string()) + rss_delta_bytes.map_or_else(|| "null".into(), |value| value.to_string()), + stable_elapsed.as_millis(), + stable_cpu_delta.map_or_else(|| "null".into(), |value| value.as_millis().to_string()), + stable_cpu_percent.map_or_else(|| "null".into(), |value| format!("{value:.3}")) ); } diff --git a/docs/architecture/decisions/0003-typescript-runtime-gate.md b/docs/architecture/decisions/0003-typescript-runtime-gate.md index 877037d..313da69 100644 --- a/docs/architecture/decisions/0003-typescript-runtime-gate.md +++ b/docs/architecture/decisions/0003-typescript-runtime-gate.md @@ -126,11 +126,25 @@ spike 同步到当前 `floatile:widget@1.2.0`/`uiApiVersion = 1.6.0`。生成组 rejection;共享安全向量的 Broker deny、invalid patch、fuel、墙钟预算、StoreLimits 与 peer 存活也由 同一 TypeScript component 实测通过。 -本次数据来自同一 Linux 测试机的一次串行冷运行,保留为候选比较证据,不冒充稳定态 CPU或 -Windows/macOS 证据。固定 SHA 只用于复现未发布修复,不是 Floatile 的生产依赖。 +修正 Linux 进程 CPU 采样为汇总 `/proc/self/task/*/schedstat` 后,同机 release 冷运行与 3 秒稳定窗口为: + +- 单实例 startup 154 ms、首 tick 1,167 ms、RSS 增量 88,014,848 B;稳定态 CPU 2 ms / 3,005 ms, + 即单核 0.077%; +- 10 实例 startup 1,365 ms、全部首 tick 2,377 ms、RSS 增量 324,501,504 B;稳定态 CPU + 8 ms / 3,002 ms,即单核 0.284%。 + +许可预审区分两类闭包:嵌入 component 的 `componentize-qjs-runtime` normal dependency tree 中 +`componentize-qjs` 为 Apache-2.0、`rquickjs`/`rquickjs-core`/`rquickjs-sys` 为 MIT,其余已识别项属于 +仓库现有 allowlist;来自 wasm-tools git SHA 的 `wit-dylib-ffi` 继承 workspace 的 +`Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT`。但完整 CLI 构建期依赖还经 `ureq` 引入 +CDLA-Permissive-2.0 的 `webpki-roots`,按 Floatile 当前 `deny.toml` 会拒绝。构建工具与最终 component +的 NOTICE 生成也尚未自动化,因此许可门仍未通过,不新增例外。 + +这些数据来自同一 Linux 测试机的一次串行冷运行;3 秒窗口是 Linux 稳定态 CPU 候选证据,不替代 +长时压力或 Windows/macOS 证据。固定 SHA 只用于复现未发布修复,不是 Floatile 的生产依赖。 这些数据显著优于本 ADR 的 StarlingMonkey 对照,但尚不改变 no-go 决策:修复未进入上游发布版, -Windows/macOS 构建、稳定态 CPU、许可/NOTICE 仍未完成;QuickJS component 还额外导出 runtime +Windows/macOS 构建及稳定态 CPU、许可/NOTICE 仍未完成;QuickJS component 还额外导出 runtime 初始化函数 `init`,虽不增加 host capability,仍需在生产契约门中决定是否允许或由 adapter 隐藏。 ## 后果与下一步 @@ -140,6 +154,6 @@ Windows/macOS 构建、稳定态 CPU、许可/NOTICE 仍未完成;QuickJS comp - 代价:P0 的 TypeScript clock 与公共 SDK 继续阻塞。 - 本 PR:已将 QuickJS 问题最小化为任意 method 参数的 receiver 错位,形成可直接提交上游的修复与 回归测试,并把 spike 改为 StarlingMonkey/QuickJS 共用同一宿主行为向量。 -- 下一个 PR:在上游合并并发布固定版本后锁定该版本,补 Windows/macOS 构建、稳定态 CPU、 +- 下一个 PR:在上游合并并发布固定版本后锁定该版本,补 Windows/macOS 构建及稳定态 CPU、 QuickJS/生成 component 的许可与 NOTICE、额外 `init` export 契约处理;全部通过后新建后继 ADR, 再决定是否启动公共 TypeScript SDK。 diff --git a/spikes/typescript-runtime/README.md b/spikes/typescript-runtime/README.md index 4e1e6ac..d670609 100644 --- a/spikes/typescript-runtime/README.md +++ b/spikes/typescript-runtime/README.md @@ -41,8 +41,9 @@ lifecycle error 向量会真实穿过 SDK、adapter 和 Wasmtime host;它不 墙钟预算和 StoreLimits/peer 隔离。普通 JavaScript 堆分配失败是可捕获异常,不冒充 Wasmtime 线性内存 trap;内存向量由宿主 StoreLimits 验证。 -`pnpm measure` 在 release 下串行输出单/10 实例启动、首 tick 与 RSS(CPU 需在外层用 -`time -p`/平台 profiler 采样)。最小 receiver/参数复现位于 +`pnpm measure` 在 release 下串行输出单/10 实例启动、首 tick、RSS,以及首 tick 后 3 秒窗口的 +进程 CPU 毫秒/单核百分比。Linux 指标汇总所有 `/proc/self/task/*/schedstat`,不是只读调用线程; +其他平台仍以 `floatile-platform::process_metrics` 的平台实现为准。最小 receiver/参数复现位于 `repro/quickjs-resource-method/`。QuickJS 对照与资源数据记录在 ADR-0003;在修复尚未发布且 许可/三平台门未完成前结论仍为 no-go。仓库中的 private TypeScript SDK/UI codegen 只用于推进 共享契约和测试,CLI TypeScript 模板与可发布 SDK 仍不得启用。 From 2f24451b4eefe02c7ced761e6ad763508b4d741d Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:55:46 +0800 Subject: [PATCH 15/22] ci(sdk): gate the QuickJS candidate on three systems Build the exact unmerged componentize-qjs repair from source on Ubuntu, macOS, and Windows, then generate and verify the Floatile WIT 1.2 TypeScript component. Run the full shared behavior and security host suite on Linux while keeping the candidate separate from production dependencies. Refs: PP-M7, ADR-0003, componentize-qjs#76 Tests: workflow syntax and fixed SHA reviewed locally Unverified: hosted-runner execution requires the pull request workflow --- .../typescript-runtime-candidate.yml | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/typescript-runtime-candidate.yml diff --git a/.github/workflows/typescript-runtime-candidate.yml b/.github/workflows/typescript-runtime-candidate.yml new file mode 100644 index 0000000..6e98119 --- /dev/null +++ b/.github/workflows/typescript-runtime-candidate.yml @@ -0,0 +1,98 @@ +name: TypeScript runtime candidate + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/typescript-runtime-candidate.yml" + - "conformance/**" + - "crates/floatile-runtime/**" + - "sdk/typescript/**" + - "spikes/typescript-runtime/**" + - "wit/**" + +permissions: + contents: read + +concurrency: + group: typescript-runtime-${{ github.ref }} + cancel-in-progress: true + +env: + COMPONENTIZE_QJS_SHA: 7788644697ed08a841ad0910d4e99772c6fe7132 + CARGO_INCREMENTAL: 0 + +jobs: + quickjs: + name: QuickJS candidate (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: Checkout Floatile + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: floatile + persist-credentials: false + + - name: Checkout fixed componentize-qjs candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: andreiltd/componentize-qjs + ref: refs/pull/76/head + path: componentize-qjs + persist-credentials: false + + - name: Verify candidate identity + shell: bash + working-directory: componentize-qjs + run: test "$(git rev-parse HEAD)" = "$COMPONENTIZE_QJS_SHA" + + - name: Install Rust + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: 1.97.1 + targets: wasm32-wasip2 + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 26.8.0 + + - name: Set up pnpm + uses: pnpm/action-setup@c12ef9fdc5a76b59d9c8fd177c7808157c438508 # v5.0.0 + with: + version: 11.3.0 + + - name: Build fixed componentize-qjs CLI + working-directory: componentize-qjs + run: cargo build --release -p componentize-qjs-cli --features opt-size + + - name: Install locked TypeScript toolchains + shell: bash + working-directory: floatile + run: | + pnpm install --dir sdk/typescript --frozen-lockfile + pnpm install --dir spikes/typescript-runtime --frozen-lockfile + + - name: Build Floatile TypeScript component + shell: bash + working-directory: floatile + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + candidate="$(cd ../componentize-qjs && pwd -W)/target/release/componentize-qjs.exe" + else + candidate="$(cd ../componentize-qjs && pwd)/target/release/componentize-qjs" + fi + FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER=1 \ + FLOATILE_COMPONENTIZE_QJS_BIN="$candidate" \ + pnpm --dir spikes/typescript-runtime build:quickjs + + - name: Run Linux behavior and security vectors + if: runner.os == 'Linux' + working-directory: floatile + env: + FLOATILE_SPIKE_DISABLE_RUSTC_WRAPPER: "1" + run: pnpm --dir spikes/typescript-runtime test:quickjs From f2aa9b66ab45bffd51d7ec5fae951b45c4badf92 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 14:57:13 +0800 Subject: [PATCH 16/22] fix(ci): resolve the pnpm setup action Match the pnpm/action-setup v5 reference already used by the primary CI workflow so GitHub can prepare the three candidate jobs. Refs: PP-M7, PR #51 Tests: hosted runner setup failure identified in run 33149647506 Unverified: candidate build steps rerun after push --- .github/workflows/typescript-runtime-candidate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/typescript-runtime-candidate.yml b/.github/workflows/typescript-runtime-candidate.yml index 6e98119..2089463 100644 --- a/.github/workflows/typescript-runtime-candidate.yml +++ b/.github/workflows/typescript-runtime-candidate.yml @@ -62,7 +62,7 @@ jobs: node-version: 26.8.0 - name: Set up pnpm - uses: pnpm/action-setup@c12ef9fdc5a76b59d9c8fd177c7808157c438508 # v5.0.0 + uses: pnpm/action-setup@v5 with: version: 11.3.0 From f659e19f7961b97b92d01d1720e58f978f0d9975 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 15:22:38 +0800 Subject: [PATCH 17/22] fix(sdk): invoke pnpm portably on Windows Use the Windows command shim when the TypeScript component builder launches the SDK package build, matching the existing local tool executable handling. Refs: PP-M7, PR #51 Tests: node --check spikes/typescript-runtime/scripts/build-backend.mjs; pnpm --dir sdk/typescript typecheck; pnpm --dir sdk/typescript test; git diff --check Unverified: hosted Windows candidate rerun after push --- spikes/typescript-runtime/scripts/build-backend.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spikes/typescript-runtime/scripts/build-backend.mjs b/spikes/typescript-runtime/scripts/build-backend.mjs index d03fb07..e79d519 100644 --- a/spikes/typescript-runtime/scripts/build-backend.mjs +++ b/spikes/typescript-runtime/scripts/build-backend.mjs @@ -18,8 +18,9 @@ const executable = (name) => "node_modules/.bin", process.platform === "win32" ? `${name}.CMD` : name, ); +const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; -execFileSync("pnpm", ["--dir", "sdk/typescript", "build"], { +execFileSync(pnpm, ["--dir", "sdk/typescript", "build"], { cwd: workspace, stdio: "inherit", }); From 05a135b8373726f3c1df4a6f52358e8580c555e5 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 15:46:31 +0800 Subject: [PATCH 18/22] fix(sdk): launch the pnpm shim through Windows shell Allow Node 26 to execute the fixed pnpm.cmd build command on Windows while retaining direct executable invocation on Unix hosts. Refs: PP-M7, PR #51 Tests: node --check spikes/typescript-runtime/scripts/build-backend.mjs; pnpm --dir sdk/typescript typecheck; pnpm --dir sdk/typescript test; git diff --check Unverified: hosted Windows candidate rerun after push --- spikes/typescript-runtime/scripts/build-backend.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/spikes/typescript-runtime/scripts/build-backend.mjs b/spikes/typescript-runtime/scripts/build-backend.mjs index e79d519..135d5f4 100644 --- a/spikes/typescript-runtime/scripts/build-backend.mjs +++ b/spikes/typescript-runtime/scripts/build-backend.mjs @@ -22,6 +22,7 @@ const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; execFileSync(pnpm, ["--dir", "sdk/typescript", "build"], { cwd: workspace, + shell: process.platform === "win32", stdio: "inherit", }); From 0b7e2a533ebdc7471c433259e0d5be8225715be1 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 16:08:25 +0800 Subject: [PATCH 19/22] fix(sdk): launch local Node shims portably Apply the Windows shell requirement to jco and tsc command shims while keeping native runtime executables on direct invocation. Refs: PP-M7, PR #51 Tests: node --check spikes/typescript-runtime/scripts/build-backend.mjs; pnpm --dir sdk/typescript typecheck; pnpm --dir sdk/typescript test; git diff --check Unverified: hosted Windows candidate rerun after push --- .../typescript-runtime/scripts/build-backend.mjs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/spikes/typescript-runtime/scripts/build-backend.mjs b/spikes/typescript-runtime/scripts/build-backend.mjs index 135d5f4..04c130d 100644 --- a/spikes/typescript-runtime/scripts/build-backend.mjs +++ b/spikes/typescript-runtime/scripts/build-backend.mjs @@ -19,6 +19,11 @@ const executable = (name) => process.platform === "win32" ? `${name}.CMD` : name, ); const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const localToolOptions = { + cwd: spikeDir, + shell: process.platform === "win32", + stdio: "inherit", +}; execFileSync(pnpm, ["--dir", "sdk/typescript", "build"], { cwd: workspace, @@ -42,14 +47,11 @@ execFileSync( "--strict", "--quiet", ], - { cwd: spikeDir, stdio: "inherit" }, + localToolOptions, ); if (backend === "starlingmonkey") { - execFileSync(executable("tsc"), ["--noEmit"], { - cwd: spikeDir, - stdio: "inherit", - }); + execFileSync(executable("tsc"), ["--noEmit"], localToolOptions); execFileSync( executable("jco"), [ @@ -64,7 +66,7 @@ if (backend === "starlingmonkey") { "--out", output, ], - { cwd: spikeDir, stdio: "inherit" }, + localToolOptions, ); } else if (process.env.FLOATILE_COMPONENTIZE_QJS_BIN) { execFileSync( From e4b94f84adc0391dc6e21c5d6b35c333bf232082 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 16:14:30 +0800 Subject: [PATCH 20/22] fix(sdk): normalize generated TypeScript output Emit exactly one trailing newline from the UI builder generator so generated sources satisfy the repository diff hygiene gate deterministically. Refs: PP-M7, NFR-MAINT-01, PR #51 Tests: RUSTC_WRAPPER= cargo run -p floatile-cli --locked -- schema ui target/floatile-ui-registry.json; node sdk/typescript/scripts/generate-components.mjs target/floatile-ui-registry.json sdk/typescript/src/generated/components.ts --check; pnpm --dir sdk/typescript typecheck; pnpm --dir sdk/typescript test; git diff --check origin/dev Unverified: hosted candidate and primary CI rerun after push --- sdk/typescript/scripts/generate-components.mjs | 1 + sdk/typescript/src/generated/components.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/typescript/scripts/generate-components.mjs b/sdk/typescript/scripts/generate-components.mjs index 86230af..8ade066 100644 --- a/sdk/typescript/scripts/generate-components.mjs +++ b/sdk/typescript/scripts/generate-components.mjs @@ -43,6 +43,7 @@ for (const spec of registry.components) { source += ` return component(${JSON.stringify(spec.name)}, props);\n`; source += "}\n\n"; } +source = `${source.trimEnd()}\n`; if (mode === "--check") { const existing = await readFile(output, "utf8"); diff --git a/sdk/typescript/src/generated/components.ts b/sdk/typescript/src/generated/components.ts index 053c9ca..43b98ce 100644 --- a/sdk/typescript/src/generated/components.ts +++ b/sdk/typescript/src/generated/components.ts @@ -264,4 +264,3 @@ export interface SparklineProps { export function Sparkline(props: SparklineProps): View { return component("Sparkline", props); } - From 33f58a35df8e8b4bf1b4d658ae142dc5211ef997 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 16:35:20 +0800 Subject: [PATCH 21/22] fix(sdk): verify components through the Windows shim Run the jco command shim through the Windows shell during component contract inspection while preserving direct execution on Unix hosts. Refs: PP-M7, PR #51 Tests: node --check spikes/typescript-runtime/scripts/verify-component.mjs; node spikes/typescript-runtime/scripts/verify-component.mjs quickjs target/typescript-runtime-spike/clock-typescript-quickjs.wasm; git diff --check origin/dev Unverified: hosted Windows candidate rerun after push --- spikes/typescript-runtime/scripts/verify-component.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/spikes/typescript-runtime/scripts/verify-component.mjs b/spikes/typescript-runtime/scripts/verify-component.mjs index 03f56cc..c98ff1f 100644 --- a/spikes/typescript-runtime/scripts/verify-component.mjs +++ b/spikes/typescript-runtime/scripts/verify-component.mjs @@ -21,6 +21,7 @@ const jco = resolve( const world = execFileSync(jco, ["wit", componentPath], { cwd: spikeDir, encoding: "utf8", + ...(process.platform === "win32" ? { shell: true } : {}), }); const imports = [...world.matchAll(/^\s*import\s+([^;]+);$/gm)].map( From 5eca079402801ed8a35dd960003c0de7ce7a6675 Mon Sep 17 00:00:00 2001 From: CN059 Date: Fri, 28 Aug 2026 16:56:27 +0800 Subject: [PATCH 22/22] docs(sdk): close the PP-M7 implementation milestone Record the completed dual-language author contract, shared adversarial vectors, migration and documentation gates, and reproducible three-host candidate build. Keep the production TypeScript runtime, CLI template, public SDK, and distribution explicitly disabled behind the unresolved upstream release and license gates. Refs: PP-M7, ADR-0003, FR-PLUGIN-01, F11, PR #51 Tests: TypeScript runtime candidate run 33155960160; git diff --check origin/dev Unverified: Windows/macOS stable resource measurements; upstream componentize-qjs release; automated license/NOTICE inventory; production handling of the extra init export --- .../decisions/0003-typescript-runtime-gate.md | 11 ++++++++--- docs/architecture/init-plan.md | 10 ++++++---- docs/architecture/technology-stack.md | 2 +- docs/product/plugin-platform-roadmap.md | 19 ++++++++++++------- docs/product/requirements.md | 2 +- 5 files changed, 28 insertions(+), 16 deletions(-) diff --git a/docs/architecture/decisions/0003-typescript-runtime-gate.md b/docs/architecture/decisions/0003-typescript-runtime-gate.md index 313da69..fee3e96 100644 --- a/docs/architecture/decisions/0003-typescript-runtime-gate.md +++ b/docs/architecture/decisions/0003-typescript-runtime-gate.md @@ -141,10 +141,15 @@ CDLA-Permissive-2.0 的 `webpki-roots`,按 Floatile 当前 `deny.toml` 会拒 的 NOTICE 生成也尚未自动化,因此许可门仍未通过,不新增例外。 这些数据来自同一 Linux 测试机的一次串行冷运行;3 秒窗口是 Linux 稳定态 CPU 候选证据,不替代 -长时压力或 Windows/macOS 证据。固定 SHA 只用于复现未发布修复,不是 Floatile 的生产依赖。 +长时压力或 Windows/macOS 资源证据。固定 SHA 只用于复现未发布修复,不是 Floatile 的生产依赖。 + +GitHub Actions [run 33155960160](https://github.com/powercess/floatile/actions/runs/33155960160) +又从同一固定 SHA 在 Ubuntu、macOS 和 Windows 构建候选 CLI,并在三个系统生成及校验 Floatile +TypeScript component;Ubuntu 额外执行上述完整行为和安全向量。该矩阵证明候选的三宿主构建可复现, +不把 Linux 性能数字外推为另外两个系统的资源结论。 这些数据显著优于本 ADR 的 StarlingMonkey 对照,但尚不改变 no-go 决策:修复未进入上游发布版, -Windows/macOS 构建及稳定态 CPU、许可/NOTICE 仍未完成;QuickJS component 还额外导出 runtime +Windows/macOS 稳定态资源证据、许可/NOTICE 仍未完成;QuickJS component 还额外导出 runtime 初始化函数 `init`,虽不增加 host capability,仍需在生产契约门中决定是否允许或由 adapter 隐藏。 ## 后果与下一步 @@ -154,6 +159,6 @@ Windows/macOS 构建及稳定态 CPU、许可/NOTICE 仍未完成;QuickJS comp - 代价:P0 的 TypeScript clock 与公共 SDK 继续阻塞。 - 本 PR:已将 QuickJS 问题最小化为任意 method 参数的 receiver 错位,形成可直接提交上游的修复与 回归测试,并把 spike 改为 StarlingMonkey/QuickJS 共用同一宿主行为向量。 -- 下一个 PR:在上游合并并发布固定版本后锁定该版本,补 Windows/macOS 构建及稳定态 CPU、 +- 下一个 PR:在上游合并并发布固定版本后锁定该版本,补 Windows/macOS 稳定态资源证据、 QuickJS/生成 component 的许可与 NOTICE、额外 `init` export 契约处理;全部通过后新建后继 ADR, 再决定是否启动公共 TypeScript SDK。 diff --git a/docs/architecture/init-plan.md b/docs/architecture/init-plan.md index feab4c2..ee0878a 100644 --- a/docs/architecture/init-plan.md +++ b/docs/architecture/init-plan.md @@ -125,10 +125,12 @@ - 先用 ADR 选择 TypeScript adapter/runtime;禁止公开非标准 TypeScript 子集或 Broker 外 ambient API。 - 与 Rust 共用 UI/component/capability/error/behavior vectors;实现 TSX 构建期 View 和同一 WIT world。 - 验收:Rust/TypeScript clocks 行为一致;单/10 实例 CPU/RSS/冷启动/包大小和三平台构建记录。 -- 当前结论:ADR-0003 已完成 Linux 候选 spike;StarlingMonkey 资源门失败,componentize-qjs 0.4.3 - 契约门失败,上游未发布修复候选已通过 Linux 契约/隔离/资源复测,但三平台、许可/NOTICE 和正式 - 发布门未完成,因此仍为 **no-go**。private TypeScript SDK/UI codegen 已开始,CLI TypeScript 模板 - 与可发布 SDK 尚未启用,F11 不得标记完成。 +- 当前结论:ADR-0003 已完成候选 spike;StarlingMonkey 资源门失败,componentize-qjs 0.4.3 + 契约门失败。上游未发布修复候选已通过 Ubuntu/macOS/Windows 组件构建,并在 Linux 通过共享 + 契约/恶意输入/隔离/资源复测;private TypeScript SDK、Rust Registry 同源 UI codegen、WIT adapter + 和 ESM/declarations 包边界已完成。上游发布、许可/NOTICE 与额外 `init` export 仍未完成,因此生产 + runtime 保持 **no-go**,CLI TypeScript 模板与公共 SDK 不启用;F11 的仓库内双语言契约证据已具备, + 但产品发布门仍不得标记完成。 ### S6 — `.floatile` 包 + 安装 diff --git a/docs/architecture/technology-stack.md b/docs/architecture/technology-stack.md index d344b50..ef05317 100644 --- a/docs/architecture/technology-stack.md +++ b/docs/architecture/technology-stack.md @@ -13,7 +13,7 @@ | 平台 API | windows-sys 0.52.x + x11rb 0.13.x(`randr`、`shape`)+ objc2 0.6.x(`app-kit`/`foundation`)+ mach2 0.6.x | 平台句柄、Windows 窗口操作、X11 compositor/SHAPE/EWMH/RandR/热键探测,以及 macOS NSWindow/NSScreen/进程指标与 Carbon 全局热键;只允许 `floatile-platform` 直接依赖。 | | 插件 ABI | WIT + WASM Component Model,guest `wasm32-wasip2` | 版本化接口、无原生句柄;`wit/` 为唯一源。 | | 插件 runtime | Wasmtime 47 + wasmtime-wasi p2(S5b 已引入,空 WASI 上下文实现零 ambient) | Component Model、异步调用、fuel 与资源限制;引入时固定兼容版本组。 | -| TypeScript adapter | ADR-0003 no-go | StarlingMonkey 功能通过但资源门失败;componentize-qjs 0.4.3 在 `handle-event(variant)` trap。暂不发布 TypeScript SDK,后继候选仍须保持普通 TypeScript 语义、同一 WIT/Broker。 | +| TypeScript adapter | private SDK + ADR-0003 no-go runtime | TypeScript 作者契约、Rust Registry 同源 UI builder、WIT 1.2 adapter 与共享 conformance vectors 已实现;componentize-qjs 未发布修复候选已过三系统构建和 Linux 行为/安全/资源门,但上游发布、许可/NOTICE 与额外 `init` export 未决。暂不启用 CLI 模板或公共发布。 | | 异步 | Tokio(runtime/services 已引入,S5b) | 后台 I/O/runtime;Slint 主线程只跑事件循环。 | | 存储 | SQLite + rusqlite bundled | 单文件、事务、跨平台;migration 前向追加(v1 layout 表已落地) | | 序列化/错误 | serde、serde_json、thiserror;manifest JSON Schema 单源使用 schemars + jsonschema | 契约类型、结构化校验错误;类型→schema 同源生成独立 manifest.schema.json 产物 | diff --git a/docs/product/plugin-platform-roadmap.md b/docs/product/plugin-platform-roadmap.md index 174d429..ebf0be6 100644 --- a/docs/product/plugin-platform-roadmap.md +++ b/docs/product/plugin-platform-roadmap.md @@ -173,7 +173,7 @@ exit code。生成项目必须只依赖可获得的 SDK,并能从干净目录 | PP-M4 | Rust 作者闭环 | 已完成(自动化契约与 Xvfb 验证;公开发布受许可门阻断) | 可发布方式待许可决定的 SDK 解析、生成模板修复、dev/test/preview/build/install/run/inspect | 干净目录中的示例插件无需仓库私有路径即可完成全流程;JSON 契约有测试 | SDK、CLI、runtime、shell、docs | | PP-M5 | 外部数据平台 | 已完成(自动化契约验证) | Connection、Credential Vault、HTTPS Broker、调度、缓存、重试、限流和连接健康状态 | AI 余额参考插件只使用通用能力,且 secret 不进入 guest、日志、State 或包 | `core`、`store`、`services`、shell、WIT、SDK | | PP-M6 | UI 平台 | 已完成(自动化契约验证) | loading/empty/error、列表/网格、badge、progress、sparkline/chart、主题与响应式布局 | 参考插件无需第三方 Slint/HTML 即可表达监控型 UI;预算和无障碍语义有契约 | UI schema、renderer、SDK、shell | -| PP-M7 | SDK 与语言生态 | 进行中 | Rust API 稳定化;在工具链可行后接入 TypeScript;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | +| PP-M7 | SDK 与语言生态 | 已完成(自动化契约验证;TypeScript 生产启用受运行时发布与许可门阻断) | Rust API 稳定化;TypeScript 作者 SDK 与候选 adapter;生成文档、迁移指南和 conformance kit | 双语言通过相同 contract vectors、恶意输入和端到端示例;不存在宿主语义分叉 | SDK、plugin API、CLI、CI | | PP-M8 | 分发与信任 | 已完成(自动化契约验证;公开分发受许可门阻断) | publisher/signing、来源与信任、权限 diff、兼容性解析、更新/回滚和可恢复安装 | 安装与升级能解释权限变化和失败原因;篡改/降级/回滚路径有测试 | CLI、core、store、shell、distribution | | PP-M9 | 组合与自动化 | 规划中 | 宿主事件、定时/系统触发、受控 pub/sub、工作流和通知 | 插件间不直接持有句柄;事件有 schema、scope、背压、循环检测和审计 | core、runtime、services、WIT、SDK | | PP-M10 | 产品化与发布门禁 | 规划中 | 跨平台交互证据、性能、许可、安装器、更新器、无障碍、崩溃恢复和运维诊断 | 产品目标平台通过发布矩阵;许可 ADR 和分发门禁完成;关键 SLO 有实测证据 | 全仓库 | @@ -218,11 +218,15 @@ Config Schema 表单、observed 状态和手动 retry;Linux X11/Xvfb 已自动 `widget.ftui`,证明参考插件覆盖上述监控 UI 契约且包内不含第三方 `.slint`/HTML;schema、renderer、 SDK 与 shell 的契约/真实 Slint 无头编译测试均通过。真实屏幕阅读器行为、跨平台视觉与完整无障碍仍属 PP-M10 环境验收,不把它们误记为 PP-M6 自动化契约证据; -- PP-M7 已启动 Rust SDK 契约稳定化:`Widget::start/event` 的 `WidgetResult` 与 WIT - `widget-error` 对齐,`floatile_sdk::prelude` 定义作者入口;仓库根版本化 lifecycle JSON 覆盖全部 - guest error variant,并由 Rust SDK 与真实 host/WASM 共同消费。`floatile conformance --json` - 为后继语言 adapter 和 CI 输出同一 suite。生成 API 文档、`migrate`、完整行为/恶意输入向量与 - TypeScript SDK 仍未完成,不能据此宣称 PP-M7 退出门通过; +- PP-M7 已完成 Rust SDK 契约稳定化:`Widget::start/event` 的 `WidgetResult` 与 WIT + `widget-error` 对齐,`floatile_sdk::prelude` 定义作者入口;仓库根版本化 lifecycle/security JSON + 由 Rust SDK、TypeScript SDK 与真实 host/WASM 共同消费。`floatile conformance --json` 输出同一 + suite;Rust API 文档以 warning-free CI 为门,迁移指南和可恢复、幂等的 `migrate` 已落地。private + TypeScript 作者包从 Rust UI Registry 生成类型化 builder,输出 ESM/declarations,并经 lifecycle + adapter 生成 WIT 1.2 component;固定 QuickJS 修复候选在 Linux 通过完整行为、恶意输入、Broker + deny、fuel/epoch/内存隔离与资源证据,在 Ubuntu/macOS/Windows hosted runner 通过同一组件构建。 + 这满足仓库内双语言同契约退出门,但不等于生产启用:上游修复尚未合并发布、许可/NOTICE 门未过, + 因此 TypeScript CLI 模板、公共 SDK 和分发继续关闭; - PP-M8 已由 ADR-0005 固定 detached DSSE/Ed25519 与宿主持有的 publisher/key trust。CLI 受信安装 强制当前信任、撤销、anti-rollback 和权限扩大显式确认;SQLite journal 可恢复 rename 与水位之间的 崩溃窗口。显式回滚只重绑 stopped 实例到重新验签、migration 兼容且不恢复旧权限的历史 @@ -231,7 +235,8 @@ Config Schema 表单、observed 状态和手动 retry;Linux X11/Xvfb 已自动 Connection grant 是实例级宿主状态,安装与回滚均保持原绑定不变;任何后续换绑仍走 PP-M5 独立授权。 全 workspace 自动化、`wasm32-wasip2` SDK、advisories/bans/sources 已通过;Windows/macOS/Wayland 分发实测属于 PP-M10,公开产物继续由 NFR-LEGAL-01 与 Slint license gate 阻断; -- TypeScript runtime 的 ADR-0003 spike 结论是 no-go,不能把语言目标标记为完成; +- TypeScript runtime 的 ADR-0003 仍是生产 no-go;PP-M7 的作者契约和候选端到端证据已经完成,但 + runtime 选型、公共发布及分发授权属于必须继续保持关闭的外部门禁; - 设置、连接管理、权限解释和开发诊断还没有完整产品入口。 因此最近的 PR 顺序应优先建设通用底座: diff --git a/docs/product/requirements.md b/docs/product/requirements.md index 30dc8a1..85c8cc1 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -62,7 +62,7 @@ HTML/WebView、原生插件和完整无障碍均不在 P0。接口可以预留 | 布局/存储 | 部分验证 | 核心层已有强类型 monitor/DPI/物理坐标模型和平台无关恢复算法;SQLite v2 migration 已持久化物理尺寸、scale factor 与 `lost_monitor`;shell 已接入启动保存/恢复(位置/尺寸/模式)、拖动/缩放/模式/热键/退出保存与显示器变化重恢复,Xvfb+Openbox 与 macOS 单屏重启恢复已实测;真实多屏/DPI/热插拔与 Windows 实机验证待做 | | 插件实例内核(PP-M1) | 已实现,部分验证 | SQLite v4 实例模型和 `floatile instance create/list/get/configure/start/stop/delete` 已打通;创建/配置与 shell 恢复共用安装目录完整性和 Config JSON Schema 复验。shell 控制面提供安装/实例列表、Schema 驱动配置、desired 启停/删除、observed `starting/running/failed/stopped` 与手动 retry;SQLite、安装文件和配置解析均在有界后台 worker。runtime 只有在 WASM `start()` 成功后才报告 running,晚期退出按稳定 code 隔离。Linux X11/Xvfb 自动证据已验证同包双窗口、单实例安装缺失失败隔离和恢复后手动 retry;Windows、macOS、Wayland 控制面交互与真实桌面多窗口仍未验证。 | | Rust 作者闭环(PP-M4) | 已实现,Xvfb 验证 | Rust SDK 的 WIT 发行快照受根事实源 drift 测试约束,生成模板可从三个独立 Cargo 包快照在仓库外目录构建;`new/check/test/dev/preview/build/install/run/inspect` 已串行通过 schema v1 自动化验收,`preview/dev/run` 使用 shell 所属真实 renderer/Slint/Wasmtime/Broker 宿主,`run` 创建精确 Installation 的持久实例并推进 generation。公开 SDK registry 上传继续受 NFR-LEGAL-01/许可 ADR 阻断;Windows、macOS、Wayland 作者窗口交互未验证。 | -| 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;Rust 作者 SDK(`Widget`/`View`/`Context`/`#[derive(State)]`/`impl_export_widget!`/`build_ftui`)已实现且 clock-wasm 已改用,PP-M7 已增加 fallible lifecycle、稳定作者 prelude、语言无关 lifecycle conformance JSON 与 `floatile conformance` 自动化入口;CLI `new/validate/build` 命令(模板、`.floatile` 校验、manifest 生成 + 打包)已实现;ADR-0003 的 Linux TypeScript runtime spike 已完成但结论为 no-go(StarlingMonkey 资源门失败、componentize-qjs 0.4.3 契约门失败),因此 TypeScript SDK/F11 仍未完成;生成 API 文档、`migrate`、完整跨语言/恶意输入向量、动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | +| 插件系统/WASM/SDK | 部分实现 | ADR-0001 已确定统一 UI、State Patch、串行 actor 与 Rust/TypeScript 同语义目标;`wit/floatile-widget.wit`、guest/host bindings 与 `clock.wasm` 已迁移到 ADR-0001 目标契约形状并通过 `wasm-tools validate`;`floatile-ui-schema`(IR/registry/schema 校验/绑定解析/预算/契约测试 + `uiApiVersion` 版本轴 contract vectors)已实现;`floatile-renderer`(host-only,IR→宿主控制 Slint 源码 + binding/event 槽位,多层预算/结构复验与结构化转义)已实现;ADR-0002 已决策并经 `floatile-shell::runtime_ui` 落地运行时第三方插件 UI 渲染(`slint-interpreter` 运行时编译 renderer 输出为独立原生窗口:字节/结构/预算复验前置、自窗口挂 `floatile-platform` 置顶、沿 renderer binding/event 槽位 State 投影与输入事件回投;headless F12 恶意 IR 拒绝 + Xvfb 编译/实例化/投影/事件往返全绿;`spawn_runtime_ui` 已接入 shell,按持久实例的真实 ID/Config 启动独立窗口);FTUI 解析/校验/renderer 已移到准备线程,UI event 桥容量 64、非阻塞丢弃并聚合审计;`floatile-runtime` 已实现逐调用 fuel、默认 2 s epoch 墙钟预算、内存限制、串行 actor、State Patch 原子应用与 WIT adapter 接入 Broker,并覆盖无限循环 timeout 与同 Engine peer 存活;生成组件已实例化进 shell 窗口(`build.rs` 写入 gitignore 源路径、宿主 `slint!` import 嵌入 `Clock`,运行时沿 binding 槽位投影权威 State,Xvfb 下参考时钟首帧/1 Hz 更新已实测),宿主凭 `slint!` 编译生成物、不引入 `slint-build`(规避 RUSTSEC advisory);PP-M3 Capability Registry 已在 core 单源化稳定名称、暴露/参数/风险/执行类型、WIT/SDK/CLI/审计映射,并驱动 manifest schema、CLI 与 Broker 固有授权及 drift 测试;PP-M7 已完成 Rust fallible lifecycle、稳定作者 prelude、API 文档、迁移指南和 `migrate`,并让 Rust/TypeScript SDK、真实 host/WASM 共同消费 lifecycle/security vectors;private TypeScript 包由 Rust UI Registry 生成 builder,输出 ESM/declarations,通过 WIT 1.2 adapter 与固定 QuickJS 修复候选完成 Linux 行为/恶意输入/隔离/资源复测及 Ubuntu/macOS/Windows 组件构建。ADR-0003 对生产 runtime 仍为 no-go:上游修复未发布、许可/NOTICE 与额外 `init` export 未决,因此 CLI TypeScript 模板、公共 SDK 和分发保持关闭;动画/asset 预算向量、Slint interpreter 生产编译时延与三平台 UI heartbeat 证据仍缺失 | | Permission Broker / Operation(PP-M2) | 已实现,部分验证 | `floatile-services` Broker(deny-by-default 决策、scope/配额、脱敏审计 target `floatile::audit`)与 clock/log/timer/storage/metrics/theme 能力实现已完成并有测试;脱敏审计已落 SQLite `audit_log`(store v3 + shell 运行时经 `with_audit_listener`);恶意插件 fixture(`plugins/evil-wasm`)与安全集成测试已实现(拒绝 + 审计 + 宿主存活)。ADR-0004 的 Operation registry 已实现按实例/generation 的 identity、有界提交/完成/并发/结果预算、deadline、取消、迟到结果与过载;engine API v1.1 从 WIT 单源正式增加通用 cancel、元数据 completion 和首个 `storage:read` typed submit/take,Rust SDK 与真实 guest 往返测试已接通。动态撤权、真实容量数据和后续 capability adapter 仍缺失 | | 包工具链 | 已实现,部分验证 | `floatile-cli` 已实现 `.floatile` 包校验核心(zip 预算、路径穿越/碰撞/symlink/zip-bomb 拒绝、manifest/UI IR/WASM world 校验与正反例 corpus)、原子安装与可恢复 journal;`floatile-core` 提供 detached DSSE/Ed25519 verifier与确定性升级/回滚权限规划;`floatile-store` 持久化 publisher/key trust、anti-rollback 水位与原子回滚审计。CLI 受信安装强制签名/撤销/anti-rollback,权限扩大需显式接受;stopped 实例可回滚到重新验签、migration 兼容且不恢复旧权限的历史 Installation并保持最高水位。`install.json` 明确记录 `unsigned|trusted`,Shell 对 trusted 安装在持久实例启动与 retry 前按当前 trust 重新验签,对 unsigned 保留显式本地开发降级路径;三宿主分发运行与公开发布仍受平台证据及许可门禁约束。 | | 跨平台/性能证据 | 部分验证 | Windows、Linux Xvfb 与 VMware Xfce/Xorg 已回填 S1/S2 子集,两个 Linux X11 环境的 F3 穿透往返通过;Wayland 协议层(headless weston)首帧/CPU/RSS 与 F3 降级已回填;macOS 15.7.5 已回填 S1 子集(置顶 layer=3、无边框、布局恢复、首帧/RSS/CPU);穿透/拖拽/缩放的交互实测待人工复核 |