diff --git a/crates/config-source/src/lib.rs b/crates/config-source/src/lib.rs index 2219973b..40bbac62 100644 --- a/crates/config-source/src/lib.rs +++ b/crates/config-source/src/lib.rs @@ -584,7 +584,7 @@ impl WorkspaceConfigSchemaBundle { } } let source = if contributions.is_empty() { - "{}".to_string() + "{ ...Unknown }".to_string() } else { contributions .iter() @@ -1012,7 +1012,10 @@ impl SnapshotEnvironment { &error.to_string(), )]); } - let service = LanguageService::new(self); + let analysis_environment = self + .clone() + .with_schema_bundle(contract.schema_bundle.clone()); + let service = LanguageService::new(&analysis_environment); let diagnostics = self .snapshot .entries @@ -1847,6 +1850,22 @@ mod tests { assert_eq!(left.digest, right.digest); } + #[test] + fn entrypoint_can_assert_empty_workspace_schema_global() { + let snapshot = ConfigTreeSnapshot::from_entries( + 1, + [entry( + "main.dcdl", + "{ answer = 42; } as WorkspaceConfigSchema", + )], + ) + .unwrap(); + let contract = ToolchainContract::new(1, vec![path("main.dcdl")], 1); + SnapshotEnvironment::new(snapshot) + .evaluate_contract(&contract) + .unwrap(); + } + #[test] fn schema_completion_tracks_only_asserted_config_object_paths() { assert_eq!(config_field_completion_context("{ pro", 5), None); diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 4d259ab8..da3e63af 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -11,7 +11,8 @@ use serde::{Deserialize, Serialize}; use crate::{Error, Result, SqliteWorkspaceStore}; pub const MAIN_CONFIG_ENTRYPOINT: &str = "main.dcdl"; -pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{}\n"; +pub const DEFAULT_MAIN_CONFIG_SOURCE: &str = "{} as WorkspaceConfigSchema\n"; +const WORKSPACE_CONFIG_SCHEMA_ASSERTION: &str = "WorkspaceConfigSchema"; const MAX_TOOLCHAIN_UPGRADE_DIAGNOSTICS: usize = 20; fn toolchain_upgrade_diagnostics(mut diagnostics: Vec) -> Error { @@ -175,8 +176,13 @@ impl SqliteWorkspaceStore { tx.commit()?; Ok((state, requires_toolchain_refresh)) })?; + let main_needs_normalization = state + .snapshot + .get(&main_config_path()) + .is_some_and(|entry| !main_config_has_schema_assertion(&entry.content)); if requires_toolchain_refresh || state.contract.schema_bundle.fingerprint != desired_schema.fingerprint + || main_needs_normalization { let candidate = evaluate_candidate(state, &[], desired_schema)?; return self.commit_evaluated_workspace_config(workspace_id, &candidate); @@ -403,6 +409,53 @@ impl SqliteWorkspaceStore { } } +fn main_config_has_schema_assertion(source: &str) -> bool { + source.starts_with('{') + && source + .trim_end() + .ends_with(&format!("}} as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}")) +} + +fn normalize_main_config_schema_assertion( + snapshot: ConfigTreeSnapshot, +) -> Result { + let main_path = main_config_path(); + let main = snapshot.get(&main_path).ok_or_else(|| { + Error::InvalidInput(format!( + "workspace config snapshot must contain {MAIN_CONFIG_ENTRYPOINT}" + )) + })?; + if main_config_has_schema_assertion(&main.content) { + return Ok(snapshot); + } + + let source = main.content.trim(); + let normalized = if source.starts_with('{') + && source.ends_with(&format!("}} as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}")) + { + format!("{source}\n") + } else if source.starts_with('{') && source.ends_with('}') { + format!("{source} as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}\n") + } else { + return Err(Error::InvalidInput(format!( + "{MAIN_CONFIG_ENTRYPOINT} must be a top-level object so it can be asserted as {WORKSPACE_CONFIG_SCHEMA_ASSERTION}" + ))); + }; + let revision = snapshot.revision; + let mut entries = Vec::with_capacity(snapshot.entries.len()); + for entry in snapshot.entries.into_values() { + if entry.path == main_path { + entries.push( + ConfigEntry::new(entry.path, entry.content_type, normalized.clone()) + .map_err(config_error)?, + ); + } else { + entries.push(entry); + } + } + ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error) +} + fn evaluate_candidate( current: WorkspaceConfigState, changes: &[ConfigTreeChange], @@ -411,6 +464,7 @@ fn evaluate_candidate( reject_main_entrypoint_mutation(changes)?; let snapshot = current.snapshot.apply(changes).map_err(config_error)?; ensure_main_entrypoint(&snapshot)?; + let snapshot = normalize_main_config_schema_assertion(snapshot)?; let contract = main_config_contract_with_schema(schema_bundle); let evaluation = SnapshotEnvironment::new(snapshot.clone()) .evaluate_contract(&contract) @@ -1264,6 +1318,103 @@ mod tests { .content, DEFAULT_MAIN_CONFIG_SOURCE ); + assert!(main_config_has_schema_assertion(DEFAULT_MAIN_CONFIG_SOURCE)); + } + + #[tokio::test] + async fn candidate_normalizes_main_schema_assertion_only_for_entrypoint() { + let store = open_store().await; + let current = store.load_workspace_config("w-config").unwrap().unwrap(); + let candidate = store + .preview_workspace_config( + "w-config", + &ConfigPreviewRequest { + changes: vec![ + update_main(¤t, "{}"), + ConfigTreeChange::Create { + path: path("module.dcdl"), + content_type: ConfigContentType::Decodal, + content: "{}".into(), + }, + ], + entrypoints: vec![path(MAIN_CONFIG_ENTRYPOINT)], + }, + ) + .unwrap(); + + assert_eq!( + candidate + .snapshot + .get(&path(MAIN_CONFIG_ENTRYPOINT)) + .unwrap() + .content, + DEFAULT_MAIN_CONFIG_SOURCE + ); + assert_eq!( + candidate + .snapshot + .get(&path("module.dcdl")) + .unwrap() + .content, + "{}" + ); + } + + #[tokio::test] + async fn materialization_upgrades_legacy_main_and_preserves_prior_revision() { + let store = open_store().await; + let current = store.load_workspace_config("w-config").unwrap().unwrap(); + let legacy_snapshot = ConfigTreeSnapshot::from_entries( + current.snapshot.revision + 1, + [ConfigEntry::new( + path(MAIN_CONFIG_ENTRYPOINT), + ConfigContentType::Decodal, + "{}", + ) + .unwrap()], + ) + .unwrap(); + let legacy = WorkspaceConfigState { + projection_digest: current.projection_digest.clone(), + snapshot: legacy_snapshot, + contract: current.contract.clone(), + }; + store + .with_conn_mut(|conn| { + conn.execute( + "DELETE FROM workspace_config_entries WHERE workspace_id = ?1", + params!["w-config"], + )?; + insert_materialized_state(conn, "w-config", &legacy, "2026-08-14T00:00:00Z") + }) + .unwrap(); + + let upgraded = store + .ensure_workspace_config_materialized_with_schema( + "w-config", + "2026-08-14T00:00:01Z", + current.contract.schema_bundle.clone(), + ) + .unwrap(); + assert_eq!(upgraded.snapshot.revision, legacy.snapshot.revision + 1); + assert_eq!( + upgraded + .snapshot + .get(&path(MAIN_CONFIG_ENTRYPOINT)) + .unwrap() + .content, + DEFAULT_MAIN_CONFIG_SOURCE + ); + assert_eq!( + store + .load_workspace_config_revision("w-config", legacy.snapshot.revision) + .unwrap() + .unwrap() + .get(&path(MAIN_CONFIG_ENTRYPOINT)) + .unwrap() + .content, + "{}" + ); } #[tokio::test] diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index bd4ca71a..d3ca12e0 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -14,8 +14,6 @@ } from "./types.ts"; const MAIN_ENTRYPOINT = "main.dcdl"; - const WORKSPACE_SCHEMA_ASSERTION = "WorkspaceConfigSchema"; - const WORKSPACE_SCHEMA_SUFFIX = `} as ${WORKSPACE_SCHEMA_ASSERTION}`; let { workspaceId }: { workspaceId: string } = $props(); let treeState = $state(null); @@ -42,7 +40,6 @@ treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined, ); const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT); - const mainSchemaWrapped = $derived(mainSelected && hasWorkspaceSchemaWrapper(source)); const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0)); const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest); @@ -117,35 +114,6 @@ return [MAIN_ENTRYPOINT]; } - function hasWorkspaceSchemaWrapper(value: string): boolean { - const sourceWithoutTrailingWhitespace = value.trimEnd(); - return value.startsWith("{") && sourceWithoutTrailingWhitespace.endsWith(WORKSPACE_SCHEMA_SUFFIX); - } - - async function wrapMainWithWorkspaceSchema() { - if (!toolchain || !mainSelected || mainSchemaWrapped || busy) return; - const candidate = source.trim(); - if (!candidate.startsWith("{") || !candidate.endsWith("}")) { - status = "WorkspaceConfigSchema can only wrap a top-level object source."; - return; - } - busy = true; - try { - source = await toolchain.format(`${candidate} as ${WORKSPACE_SCHEMA_ASSERTION}`); - diagnostics = await toolchain.analyze(selectedPath, source); - preflightDigest = ""; - candidateContract = null; - conflict = false; - status = diagnostics.length === 0 - ? "WorkspaceConfigSchema wrapper staged. Preview and Commit to persist it." - : `${diagnostics.length} diagnostic(s) after adding WorkspaceConfigSchema.`; - } catch (error) { - status = String(error); - } finally { - busy = false; - } - } - async function analyze() { if (!toolchain || !treeState || !selectedPath) return; diagnostics = await toolchain.analyze(selectedPath, source); @@ -344,9 +312,6 @@
- {#if mainSelected && !mainSchemaWrapped} - - {/if} @@ -357,7 +322,7 @@ source = value} onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)} /> 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 e4e3e6f6..e2f01466 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/test/config-source/editor-state.test.ts b/web/workspace/test/config-source/editor-state.test.ts index af9b9cad..4c0a7b8a 100644 --- a/web/workspace/test/config-source/editor-state.test.ts +++ b/web/workspace/test/config-source/editor-state.test.ts @@ -80,7 +80,7 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () = ); }); -Deno.test("main config wrapper is an explicit canonical draft conversion", async () => { +Deno.test("main entrypoint always enables the fixed schema wrapper", async () => { const source = await Deno.readTextFile( new URL( "../../src/lib/workspace/config-source/ConfigSourceEditor.svelte", @@ -89,15 +89,12 @@ Deno.test("main config wrapper is an explicit canonical draft conversion", async ); assert( - source.includes("Wrap with WorkspaceConfigSchema") && - source.includes("wrapMainWithWorkspaceSchema") && - source.includes("fixedSchemaWrapper={mainSchemaWrapped}"), - "legacy main sources should offer an explicit conversion before fixed wrapper mode is enabled", + source.includes("fixedSchemaWrapper={mainSelected}"), + "main.dcdl should always enable fixed wrapper behavior", ); assert( - source.includes("source.trim()") && - source.includes("toolchain.format") && - source.includes("wrapper staged. Preview and Commit"), - "conversion should remain a formatted local draft until the normal commit flow", + !source.includes("Wrap with WorkspaceConfigSchema") && + !source.includes("wrapMainWithWorkspaceSchema"), + "the authoritative main source should not require an optional client-side conversion", ); }); diff --git a/web/workspace/test/config-source/wasm-parity.test.ts b/web/workspace/test/config-source/wasm-parity.test.ts index 47828453..596a8040 100644 --- a/web/workspace/test/config-source/wasm-parity.test.ts +++ b/web/workspace/test/config-source/wasm-parity.test.ts @@ -81,6 +81,12 @@ const contract: ToolchainContract = { ), }; +const mainEntrypointContract: ToolchainContract = { + ...contract, + entrypoints: ["main.dcdl"], + fingerprint: await toolchainFingerprint(["main.dcdl"], emptySchemaBundle), +}; + Deno.test("generated WASM evaluates the same virtual import contract", () => { const result = evaluate_snapshot(snapshot, contract) as { projections: Array<{ data_json: { answer: number } }>; @@ -88,6 +94,27 @@ Deno.test("generated WASM evaluates the same virtual import contract", () => { assertEquals(result.projections[0].data_json, { answer: 42 }); }); +Deno.test("generated WASM accepts the mandatory main schema assertion", () => { + const assertedSnapshot: ConfigTreeSnapshot = { + revision: 1, + digest: "sha256:asserted-main", + entries: { + "main.dcdl": { + path: "main.dcdl", + content_type: "decodal", + content: "{ answer = 42; } as WorkspaceConfigSchema\n", + content_digest: "sha256:asserted-main-entry", + }, + }, + }; + const result = evaluate_snapshot( + assertedSnapshot, + mainEntrypointContract, + ) as { projections: Array<{ data_json: { answer: number } }> }; + + assertEquals(result.projections[0].data_json, { answer: 42 }); +}); + Deno.test("generated WASM diagnostics carry snapshot provenance", () => { const diagnostics = analyze_snapshot( snapshot,