diff --git a/Cargo.lock b/Cargo.lock index de317867..2b4ab9aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -986,24 +986,24 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] name = "decodal" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b6e47d6bc66cd3cd42c8df8ff77a994a7743e889045c6b762a6dbc360ad8494" +checksum = "30e2a1ff0bf0d4160b998401a82aef64d26395e5b4798c55fbca6880a52f8e64" [[package]] name = "decodal-language-service" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25e462dce7c86743bd229ba91b831daf7928d524de9cef4ef861257ca156aa8" +checksum = "577f8cdf109dc318c6bef32c95194f0b164ccea234ab0f70a02aeb3296813b16" dependencies = [ "decodal", ] [[package]] name = "decodal-language-tools" -version = "0.2.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d8e3eb978cb1c2259df838ba2a8102bc45553d7b415216c80fc5b6a3f378c6" +checksum = "2a18ca80b27386c5dbb160c74e88e6c1bfe33fd80f3cd5108bebe800b8aa698c" dependencies = [ "decodal", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 78bd14e2..ad9a86a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,9 +98,9 @@ yoi-workspace-server = { path = "crates/workspace-server" } async-trait = "0.1" axum = "0.8" base64 = "0.22.1" -decodal = "0.2.0" -decodal-language-service = "0.2.0" -decodal-language-tools = "0.2.0" +decodal = "0.4.0" +decodal-language-service = "0.4.0" +decodal-language-tools = "0.4.0" fs4 = "0.13" futures = "0.3" libc = "0.2" diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index 3e5ba964..c4977fae 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -10,13 +10,13 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; pub const CONFIG_SOURCE_CONTRACT_VERSION: u32 = 2; -pub const DECODAL_VERSION: &str = "0.2.0"; +pub const DECODAL_VERSION: &str = "0.4.0"; pub const DEFAULT_SCHEMA_VERSION: u32 = 1; pub const DEFAULT_IMPORT_POLICY_VERSION: u32 = 1; pub const WORKSPACE_CONFIG_SCHEMA_GLOBAL: &str = "WorkspaceConfigSchema"; pub const WORKSPACE_CONFIG_SCHEMA_SOURCE: &str = "workspace-config-schema.dcdl"; pub const WORKSPACE_CONFIG_EVALUATION_SOURCE: &str = - "WorkspaceConfigSchema & import \"__MAIN_ENTRYPOINT__\""; + "import \"__MAIN_ENTRYPOINT__\" as WorkspaceConfigSchema"; pub const MAX_ENTRY_COUNT: usize = 256; pub const MAX_CHANGE_COUNT: usize = 256; pub const MAX_ENTRY_BYTES: usize = 256 * 1024; @@ -686,8 +686,11 @@ impl SnapshotEnvironment { )] })?; engine.bind_global_runtime(WORKSPACE_CONFIG_SCHEMA_GLOBAL, schema); - let evaluation_source = - WORKSPACE_CONFIG_EVALUATION_SOURCE.replace("__MAIN_ENTRYPOINT__", entrypoint.as_str()); + let evaluation_source = if contract.schema_bundle.contributions.is_empty() { + format!("import \"{}\"", entrypoint.as_str()) + } else { + WORKSPACE_CONFIG_EVALUATION_SOURCE.replace("__MAIN_ENTRYPOINT__", entrypoint.as_str()) + }; let evaluation_module = engine .add_root_source( "workspace-config-evaluation.dcdl", @@ -1270,10 +1273,9 @@ mod tests { } #[test] - fn workspace_schema_is_applied_with_normal_decodal_composition() { + fn workspace_schema_applies_defaults_with_asymmetric_decodal_validation() { let snapshot = - ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; custom = 42; }")]) - .unwrap(); + ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "{ web = {}; }")]).unwrap(); let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( "builtin:web", "web", @@ -1287,7 +1289,118 @@ mod tests { .evaluate_contract(&contract) .unwrap(); assert_eq!(result.projections[0].data_json["web"]["enabled"], false); - assert_eq!(result.projections[0].data_json["custom"], 42); + } + + #[test] + fn workspace_schema_rejects_unknown_root_and_nested_fields() { + let schema = || { + WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:web", + "web", + "1", + "{ web = { enabled = Bool default false; }; }", + ) + .unwrap()]) + .unwrap() + }; + for (source, unknown_field) in [ + ("{ web = {}; custom = 42; }", "custom"), + ("{ web = { typo = true; }; }", "typo"), + ] { + let snapshot = + ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", source)]).unwrap(); + let diagnostics = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::with_schema_bundle( + 1, + vec![path("main.dcdl")], + 1, + schema(), + )) + .unwrap_err(); + assert_eq!(diagnostics[0].path, path("main.dcdl")); + assert_eq!(diagnostics[0].kind, "constraintviolation"); + assert!(diagnostics[0].message.contains(unknown_field)); + assert!(diagnostics[0].span.end_byte > diagnostics[0].span.start_byte); + } + } + + #[test] + fn workspace_schema_supports_typed_associative_collections() { + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [entry( + "main.dcdl", + "{ features = { web = { enabled = true; }; tickets = { enabled = false; }; }; }", + )], + ) + .unwrap(); + let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:features", + "features", + "1", + "{ features = {...{ enabled = Bool; }}; }", + ) + .unwrap()]) + .unwrap(); + let result = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::with_schema_bundle( + 1, + vec![path("main.dcdl")], + 1, + schema, + )) + .unwrap(); + assert_eq!( + result.projections[0].data_json["features"]["web"]["enabled"], + true + ); + assert_eq!( + result.projections[0].data_json["features"]["tickets"]["enabled"], + false + ); + } + + #[test] + fn workspace_schema_preserves_fields_only_where_rest_is_explicit() { + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [entry( + "main.dcdl", + "{ web = { enabled = true; extension_value = 42; }; }", + )], + ) + .unwrap(); + let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:web", + "web", + "1", + "{ web = { enabled = Bool; ...Unknown }; }", + ) + .unwrap()]) + .unwrap(); + let result = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::with_schema_bundle( + 1, + vec![path("main.dcdl")], + 1, + schema, + )) + .unwrap(); + assert_eq!( + result.projections[0].data_json["web"]["extension_value"], + 42 + ); + } + + #[test] + fn unresolved_unknown_cannot_be_materialized() { + let snapshot = + ConfigTreeSnapshot::from_entries(1, [entry("main.dcdl", "Unknown")]).unwrap(); + let diagnostics = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::new(1, vec![path("main.dcdl")], 1)) + .unwrap_err(); + assert_eq!(diagnostics[0].path, path("main.dcdl")); + assert!(!diagnostics[0].message.is_empty()); } #[test] diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 1a53de62..29aeda81 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -1,8 +1,9 @@ use chrono::{SecondsFormat, Utc}; use config_source::{ - ConfigContentType, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange, ConfigTreeSnapshot, - DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, EvaluationResult, - SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle, + ConfigContentType, ConfigDiagnostic, ConfigEntry, ConfigSchemaContribution, ConfigTreeChange, + ConfigTreeSnapshot, DECODAL_VERSION, DEFAULT_IMPORT_POLICY_VERSION, DEFAULT_SCHEMA_VERSION, + EvaluationResult, SnapshotEnvironment, ToolchainContract, VirtualPath, + WorkspaceConfigSchemaBundle, }; use rusqlite::{OptionalExtension, TransactionBehavior, params}; use serde::{Deserialize, Serialize}; @@ -11,6 +12,24 @@ use crate::{Error, Result, SqliteWorkspaceStore}; pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl"; pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{}\n"; +const MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS: usize = 20; + +fn toolchain_upgrade_diagnostics(mut diagnostics: Vec) -> Error { + let omitted = diagnostics + .len() + .saturating_sub(MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS); + diagnostics.truncate(MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS); + let rendered = serde_json::to_string(&diagnostics) + .unwrap_or_else(|_| "[diagnostics could not be serialized]".to_string()); + let suffix = if omitted == 0 { + String::new() + } else { + format!("; {omitted} additional diagnostic(s) omitted") + }; + Error::InvalidInput(format!( + "workspace configuration is invalid under Decodal {DECODAL_VERSION}: {rendered}{suffix}" + )) +} fn main_config_path() -> VirtualPath { VirtualPath::parse(MAIN_CONFIG_ENTRYPOINT).expect("main config entrypoint is a valid path") @@ -125,7 +144,7 @@ impl SqliteWorkspaceStore { schema_bundle: WorkspaceConfigSchemaBundle, ) -> Result { let desired_schema = schema_bundle.clone(); - let state = self.with_conn_mut(|conn| { + let (state, requires_toolchain_refresh) = self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let workspace_exists: bool = tx.query_row( "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)", @@ -135,6 +154,16 @@ impl SqliteWorkspaceStore { if !workspace_exists { return Err(Error::WorkspaceIdMismatch); } + let stored_decodal_version = tx + .query_row( + "SELECT decodal_version FROM workspace_config_trees WHERE workspace_id = ?1", + [workspace_id], + |row| row.get::<_, String>(0), + ) + .optional()?; + let requires_toolchain_refresh = stored_decodal_version + .as_deref() + .is_some_and(|version| version != DECODAL_VERSION); let state = match load_state(&tx, workspace_id)? { Some(state) => state, None => { @@ -144,10 +173,11 @@ impl SqliteWorkspaceStore { } }; tx.commit()?; - Ok(state) + Ok((state, requires_toolchain_refresh)) })?; - if state.contract.schema_bundle.contributions.is_empty() - && !desired_schema.contributions.is_empty() + if requires_toolchain_refresh + || (state.contract.schema_bundle.contributions.is_empty() + && !desired_schema.contributions.is_empty()) { let candidate = evaluate_candidate(state, &[], desired_schema)?; return self.commit_evaluated_workspace_config(workspace_id, &candidate); @@ -507,22 +537,42 @@ pub(crate) fn load_state( } let entrypoints: Vec = serde_json::from_str(&entrypoints_json) .map_err(|error| Error::RegistryInconsistency(error.to_string()))?; - let schema_bundle = match schema_bundle_json { + let stored_schema_bundle: WorkspaceConfigSchemaBundle = match schema_bundle_json { Some(schema_bundle_json) => serde_json::from_str(&schema_bundle_json) .map_err(|error| Error::RegistryInconsistency(error.to_string()))?, None => WorkspaceConfigSchemaBundle::empty(), }; + let requires_toolchain_refresh = decodal_version != DECODAL_VERSION; + if requires_toolchain_refresh && !matches!(decodal_version.as_str(), "0.2.0" | "0.3.0") { + return Err(Error::RegistryInconsistency(format!( + "unsupported virtual config Decodal version {decodal_version} for Workspace {workspace_id}" + ))); + } + let schema_bundle = if requires_toolchain_refresh { + WorkspaceConfigSchemaBundle::compose(stored_schema_bundle.contributions) + .map_err(config_error)? + } else { + stored_schema_bundle + }; let contract = ToolchainContract::with_schema_bundle( schema_version, entrypoints, import_policy_version, schema_bundle, ); - if decodal_version != DECODAL_VERSION || contract.fingerprint != fingerprint { + if !requires_toolchain_refresh && contract.fingerprint != fingerprint { return Err(Error::RegistryInconsistency(format!( "virtual config toolchain metadata mismatch for Workspace {workspace_id}" ))); } + let projection_digest = if requires_toolchain_refresh { + SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .map_err(toolchain_upgrade_diagnostics)? + .projection_digest + } else { + projection_digest + }; Ok(Some(WorkspaceConfigState { snapshot, contract, @@ -976,6 +1026,170 @@ mod tests { assert_eq!(reloaded.projection_digest, state.projection_digest); } + #[tokio::test] + async fn toolchain_upgrade_re_evaluates_current_tree_and_preserves_prior_revision() { + let store = open_store().await; + let schema_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:test", + "test", + "1", + r#"{ test = { value = String default "initial"; }; }"#, + ) + .unwrap()]) + .unwrap(); + let current = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-13T00:00:00Z", + schema_bundle.clone(), + ) + .unwrap(); + store + .with_conn(|conn| { + conn.execute( + "UPDATE workspace_config_trees + SET decodal_version = '0.2.0', toolchain_fingerprint = 'sha256:legacy' + WHERE workspace_id = 'w-config'", + [], + )?; + conn.execute( + "UPDATE workspace_config_tree_revisions + SET toolchain_fingerprint = 'sha256:legacy' + WHERE workspace_id = 'w-config' AND revision = ?1", + [current.snapshot.revision], + )?; + Ok(()) + }) + .unwrap(); + + let refreshed = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-14T00:00:00Z", + schema_bundle, + ) + .unwrap(); + assert_eq!(refreshed.snapshot.revision, current.snapshot.revision + 1); + assert_eq!(refreshed.snapshot.digest, current.snapshot.digest); + assert_eq!(refreshed.contract.decodal_version, DECODAL_VERSION); + assert_ne!(refreshed.contract.fingerprint, "sha256:legacy"); + let prior = store + .load_workspace_config_revision("w-config", current.snapshot.revision) + .unwrap() + .unwrap(); + assert_eq!(prior, current.snapshot); + let prior_fingerprint = store + .with_conn(|conn| { + conn.query_row( + "SELECT toolchain_fingerprint + FROM workspace_config_tree_revisions + WHERE workspace_id = 'w-config' AND revision = ?1", + [current.snapshot.revision], + |row| row.get::<_, String>(0), + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!(prior_fingerprint, "sha256:legacy"); + } + + #[tokio::test] + async fn invalid_toolchain_upgrade_returns_diagnostics_without_mutating_authority() { + let store = open_store().await; + let schema_bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new( + "builtin:test", + "test", + "1", + r#"{ test = { value = String default "initial"; }; }"#, + ) + .unwrap()]) + .unwrap(); + let current = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-13T00:00:00Z", + schema_bundle.clone(), + ) + .unwrap(); + let legacy_entry = ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT), + ConfigContentType::Decodal, + "{ test = {}; custom = 42; }\n", + ) + .unwrap(); + let legacy_snapshot = + ConfigTreeSnapshot::from_entries(current.snapshot.revision, [legacy_entry.clone()]) + .unwrap(); + let manifest_json = serde_json::to_string(&legacy_snapshot.entries).unwrap(); + store + .with_conn(|conn| { + conn.execute( + "UPDATE workspace_config_entries + SET content = ?1, content_digest = ?2 + WHERE workspace_id = 'w-config' AND path = 'main.dcdl'", + rusqlite::params![legacy_entry.content, legacy_entry.content_digest], + )?; + conn.execute( + "UPDATE workspace_config_trees + SET tree_digest = ?1, decodal_version = '0.2.0', + toolchain_fingerprint = 'sha256:legacy', + projection_digest = 'sha256:legacy-projection' + WHERE workspace_id = 'w-config'", + [legacy_snapshot.digest.as_str()], + )?; + conn.execute( + "UPDATE workspace_config_tree_revisions + SET tree_digest = ?1, toolchain_fingerprint = 'sha256:legacy', + projection_digest = 'sha256:legacy-projection', manifest_json = ?2 + WHERE workspace_id = 'w-config' AND revision = ?3", + rusqlite::params![ + legacy_snapshot.digest, + manifest_json, + current.snapshot.revision + ], + )?; + Ok(()) + }) + .unwrap(); + + let error = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-14T00:00:00Z", + schema_bundle, + ) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("Decodal 0.4.0")); + assert!(message.contains("main.dcdl")); + assert!(message.contains("constraintviolation")); + let persisted = store + .with_conn(|conn| { + conn.query_row( + "SELECT revision, decodal_version, toolchain_fingerprint + FROM workspace_config_trees WHERE workspace_id = 'w-config'", + [], + |row| { + Ok(( + row.get::<_, u64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!( + persisted, + ( + current.snapshot.revision, + "0.2.0".into(), + "sha256:legacy".into() + ) + ); + } + #[tokio::test] async fn workspace_materializes_main_entrypoint() { let store = open_store().await; diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 07317361..7840ead7 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -18,7 +18,7 @@ "@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0", "@codemirror/state": "npm:@codemirror/state@6.7.1", "@codemirror/view": "npm:@codemirror/view@6.43.8", - "decodal-codemirror": "npm:decodal-codemirror@0.1.6", + "decodal-codemirror": "npm:decodal-codemirror@0.3.0", "clsx": "npm:clsx@2.1.1", "cookie": "npm:cookie@0.6.0", "devalue": "npm:devalue@5.6.4", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index 0811f3fa..d25f484e 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -12,7 +12,7 @@ "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7", "npm:clsx@2.1.1": "2.1.1", "npm:cookie@0.6.0": "0.6.0", - "npm:decodal-codemirror@0.1.6": "0.1.6_@codemirror+view@6.43.8", + "npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10", "npm:devalue@5.6.4": "5.6.4", "npm:gen-interface-jp@0.8.0": "0.8.0", "npm:set-cookie-parser@2.7.2": "2.7.2", @@ -554,8 +554,8 @@ "ms" ] }, - "decodal-codemirror@0.1.6_@codemirror+view@6.43.8": { - "integrity": "sha512-XTS5vAY+vTb/yEg5n+1yORtBPuhozG4KO0YGbYEFR8oZX0KghZuHyFEsq/CJMXF223YMC/FQt3SC19NVYGWKMw==", + "decodal-codemirror@0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10": { + "integrity": "sha512-M+Iod3UAZigpt46TmuLJIlSEBhL6KOOksyVxUOGGdlfKYCMN9oN6YG5G9tjkr3/r+Eq8ig1RipC7VZX5iiACTQ==", "dependencies": [ "@codemirror/language", "@codemirror/view", @@ -1007,7 +1007,7 @@ "npm:@sveltejs/vite-plugin-svelte@6.2.1", "npm:clsx@2.1.1", "npm:cookie@0.6.0", - "npm:decodal-codemirror@0.1.6", + "npm:decodal-codemirror@0.3.0", "npm:devalue@5.6.4", "npm:set-cookie-parser@2.7.2", "npm:shiki@3.13.0", diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts index f6e5ecf0..af61ca25 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts @@ -9,6 +9,8 @@ export function changes_between(base: any, candidate: any): any; export function complete_current(entrypoint: string, source: string, utf16_offset: number, explicit: boolean): any; +export function compose_schema_bundle(contributions: any): any; + export function evaluate_current(contract: any): any; export function evaluate_snapshot(snapshot: any, contract: any): any; @@ -27,6 +29,7 @@ export interface InitOutput { readonly apply_changes: (a: any) => [number, number, number]; readonly changes_between: (a: any, b: any) => [number, number, number]; readonly complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number]; + readonly compose_schema_bundle: (a: any) => [number, number, number]; readonly evaluate_current: (a: any) => [number, number, number]; readonly evaluate_snapshot: (a: any, b: any) => [number, number, number]; readonly format_source: (a: number, b: number) => [number, number, number, number]; diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js index 713887df..9fc330c8 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js @@ -62,6 +62,18 @@ export function complete_current(entrypoint, source, utf16_offset, explicit) { return takeFromExternrefTable0(ret[0]); } +/** + * @param {any} contributions + * @returns {any} + */ +export function compose_schema_bundle(contributions) { + const ret = wasm.compose_schema_bundle(contributions); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + /** * @param {any} contract * @returns {any} diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm index 9ca6bd64..f492dbba 100644 Binary files a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm and b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm differ diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts index 70f74b45..361b58be 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts @@ -5,6 +5,7 @@ export const analyze_snapshot: (a: any, b: number, c: number, d: number, e: numb export const apply_changes: (a: any) => [number, number, number]; export const changes_between: (a: any, b: any) => [number, number, number]; export const complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number]; +export const compose_schema_bundle: (a: any) => [number, number, number]; export const evaluate_current: (a: any) => [number, number, number]; export const evaluate_snapshot: (a: any, b: any) => [number, number, number]; export const format_source: (a: number, b: number) => [number, number, number, number]; diff --git a/web/workspace/test/config-source/decodal-grammar.test.ts b/web/workspace/test/config-source/decodal-grammar.test.ts new file mode 100644 index 00000000..56f6a288 --- /dev/null +++ b/web/workspace/test/config-source/decodal-grammar.test.ts @@ -0,0 +1,23 @@ +import { decodalLanguage } from "decodal-codemirror"; + +Deno.test("editor grammar accepts Decodal 0.4 schema syntax", () => { + const source = ` +import "main.dcdl" as WorkspaceConfigSchema +{ + features = {...{ enabled = Bool; }}; + web = { enabled = Bool; ...Unknown }; +} +`; + const tree = decodalLanguage.parser.parse(source); + const errors: string[] = []; + tree.iterate({ + enter(node) { + if (node.type.isError) { + errors.push(`${node.from}..${node.to}`); + } + }, + }); + if (errors.length > 0) { + throw new Error(`Decodal 0.4 grammar produced parse errors at ${errors.join(", ")}`); + } +}); diff --git a/web/workspace/test/config-source/wasm-parity.test.ts b/web/workspace/test/config-source/wasm-parity.test.ts index a2f30077..111d6d3e 100644 --- a/web/workspace/test/config-source/wasm-parity.test.ts +++ b/web/workspace/test/config-source/wasm-parity.test.ts @@ -1,19 +1,42 @@ /// import { assertEquals } from "jsr:@std/assert"; -// The generated wasm-bindgen loader is JavaScript with an adjacent declaration file. -// @ts-expect-error Deno checks the generated JS implementation rather than its .d.ts. import init, { analyze_snapshot, + compose_schema_bundle, evaluate_snapshot, } from "../../src/lib/workspace/config-source/generated/config_source_wasm.js"; -import type { ConfigTreeSnapshot, ToolchainContract } from "../../src/lib/workspace/config-source/types.ts"; +import type { + ConfigTreeSnapshot, + ToolchainContract, + WorkspaceConfigSchemaBundle, +} from "../../src/lib/workspace/config-source/types.ts"; const bytes = await Deno.readFile( new URL("../../src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm", import.meta.url), ); await init({ module_or_path: bytes }); +async function digestText(text: string): Promise { + const bytes = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +async function toolchainFingerprint( + entrypoints: string[], + schemaBundle: WorkspaceConfigSchemaBundle, +): Promise { + return await digestText(JSON.stringify([ + 2, + "0.4.0", + 1, + entrypoints, + 1, + schemaBundle.fingerprint, + ])); +} + const snapshot: ConfigTreeSnapshot = { revision: 4, digest: "sha256:test-tree", @@ -33,13 +56,15 @@ const snapshot: ConfigTreeSnapshot = { }, }; +const emptySchemaBundle = compose_schema_bundle([]) as WorkspaceConfigSchemaBundle; const contract: ToolchainContract = { - contract_version: 1, - decodal_version: "0.2.0", + contract_version: 2, + decodal_version: "0.4.0", schema_version: 1, entrypoints: ["workspace.dcdl"], import_policy_version: 1, - fingerprint: "sha256:test-contract", + schema_bundle: emptySchemaBundle, + fingerprint: await toolchainFingerprint(["workspace.dcdl"], emptySchemaBundle), }; Deno.test("generated WASM evaluates the same virtual import contract", () => { @@ -65,3 +90,73 @@ Deno.test("generated WASM diagnostics carry snapshot provenance", () => { assertEquals(diagnostics[0].tree_digest, "sha256:test-tree"); assertEquals(diagnostics[0].kind, "syntax"); }); + +const featuresSchema = "{ features = {...{ enabled = Bool; }}; }"; +const webSchema = "{ web = { enabled = Bool; ...Unknown }; }"; +const schemaBundle = compose_schema_bundle([ + { + provider_id: "builtin:features", + namespace: "features", + version: "1", + source: featuresSchema, + source_digest: await digestText(featuresSchema), + }, + { + provider_id: "builtin:web", + namespace: "web", + version: "1", + source: webSchema, + source_digest: await digestText(webSchema), + }, +]) as WorkspaceConfigSchemaBundle; + +function schemaSnapshot(source: string): ConfigTreeSnapshot { + return { + revision: 7, + digest: "sha256:schema-tree", + entries: { + "main.dcdl": { + path: "main.dcdl", + content_type: "decodal", + content: source, + content_digest: "sha256:main", + }, + }, + }; +} + +const schemaContract: ToolchainContract = { + contract_version: 2, + decodal_version: "0.4.0", + schema_version: 1, + entrypoints: ["main.dcdl"], + import_policy_version: 1, + schema_bundle: schemaBundle, + fingerprint: await toolchainFingerprint(["main.dcdl"], schemaBundle), +}; + +Deno.test("generated WASM applies Decodal 0.4 typed maps and explicit object rest", () => { + const result = evaluate_snapshot( + schemaSnapshot( + "{ features = { console = { enabled = true; }; }; web = { enabled = true; extension_value = 42; }; }", + ), + schemaContract, + ) as { projections: Array<{ data_json: Record }> }; + assertEquals(result.projections[0].data_json, { + features: { console: { enabled: true } }, + web: { enabled: true, extension_value: 42 }, + }); +}); + +Deno.test("generated WASM rejects unknown root fields with source provenance", () => { + let thrown: unknown; + try { + evaluate_snapshot(schemaSnapshot("{ features = {}; custom = 42; }"), schemaContract); + } catch (error) { + thrown = error; + } + const diagnostics = thrown as Array<{ path: string; kind: string }>; + assertEquals(Array.isArray(diagnostics), true); + assertEquals(diagnostics[0].path, "main.dcdl"); + assertEquals(diagnostics[0].kind, "constraintviolation"); +});