config: format sources before commit

This commit is contained in:
2026-08-15 01:28:25 +09:00
parent c5c89795e1
commit 404809ab6e
3 changed files with 143 additions and 35 deletions
+37 -2
View File
@@ -456,6 +456,40 @@ fn normalize_main_config_schema_assertion(
ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error) ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error)
} }
fn format_candidate_sources(
snapshot: ConfigTreeSnapshot,
changes: &[ConfigTreeChange],
) -> Result<ConfigTreeSnapshot> {
let mut paths = std::collections::BTreeSet::from([main_config_path()]);
for change in changes {
match change {
ConfigTreeChange::Create { path, .. } | ConfigTreeChange::Update { path, .. } => {
paths.insert(path.clone());
}
ConfigTreeChange::Rename { to, .. } => {
paths.insert(to.clone());
}
ConfigTreeChange::Delete { .. } => {}
}
}
let environment = SnapshotEnvironment::new(snapshot.clone());
let revision = snapshot.revision;
let mut entries = Vec::with_capacity(snapshot.entries.len());
for entry in snapshot.entries.into_values() {
if paths.contains(&entry.path) && entry.content_type == ConfigContentType::Decodal {
let formatted = environment.format(&entry.content).map_err(config_error)?;
entries.push(
ConfigEntry::new(entry.path, entry.content_type, formatted)
.map_err(config_error)?,
);
} else {
entries.push(entry);
}
}
ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error)
}
fn evaluate_candidate( fn evaluate_candidate(
current: WorkspaceConfigState, current: WorkspaceConfigState,
changes: &[ConfigTreeChange], changes: &[ConfigTreeChange],
@@ -465,6 +499,7 @@ fn evaluate_candidate(
let snapshot = current.snapshot.apply(changes).map_err(config_error)?; let snapshot = current.snapshot.apply(changes).map_err(config_error)?;
ensure_main_entrypoint(&snapshot)?; ensure_main_entrypoint(&snapshot)?;
let snapshot = normalize_main_config_schema_assertion(snapshot)?; let snapshot = normalize_main_config_schema_assertion(snapshot)?;
let snapshot = format_candidate_sources(snapshot, changes)?;
let contract = main_config_contract_with_schema(schema_bundle); let contract = main_config_contract_with_schema(schema_bundle);
let evaluation = SnapshotEnvironment::new(snapshot.clone()) let evaluation = SnapshotEnvironment::new(snapshot.clone())
.evaluate_contract(&contract) .evaluate_contract(&contract)
@@ -1322,7 +1357,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn candidate_normalizes_main_schema_assertion_only_for_entrypoint() { async fn candidate_normalizes_main_and_formats_changed_decodal_sources() {
let store = open_store().await; let store = open_store().await;
let current = store.load_workspace_config("w-config").unwrap().unwrap(); let current = store.load_workspace_config("w-config").unwrap().unwrap();
let candidate = store let candidate = store
@@ -1356,7 +1391,7 @@ mod tests {
.get(&path("module.dcdl")) .get(&path("module.dcdl"))
.unwrap() .unwrap()
.content, .content,
"{}" "{}\n"
); );
} }
@@ -130,28 +130,72 @@
} }
} }
async function preview() { async function formatDraftSources() {
if (!treeState) return; if (!toolchain || !treeState || !baseSnapshot) return;
await stageCurrent(); await stageCurrent();
if (draftChanges.length === 0) { const paths = new Set<string>();
status = "No draft changes to preview."; for (const change of draftChanges) {
return; if (change.kind === "create" || change.kind === "update") paths.add(change.path);
if (change.kind === "rename") paths.add(change.to);
} }
busy = true;
try { let candidate = treeState.snapshot;
let formattedAny = false;
for (const path of paths) {
const entry = candidate.entries[path];
if (!entry || entry.content_type !== "decodal") continue;
const formatted = await toolchain.format(entry.content);
if (formatted === entry.content) continue;
candidate = await toolchain.applyChanges([{
kind: "update",
path,
expected_digest: entry.content_digest,
content: formatted,
}]);
formattedAny = true;
}
if (!formattedAny) return;
treeState = { ...treeState, snapshot: candidate };
draftChanges = await toolchain.changesBetween(baseSnapshot, candidate);
source = candidate.entries[selectedPath]?.content ?? source;
preflightDigest = "";
candidateContract = null;
conflict = false;
}
async function requestCandidatePreview() {
if (!toolchain) throw new Error("config source toolchain is unavailable");
const candidate = await previewConfigTree(workspaceId, { const candidate = await previewConfigTree(workspaceId, {
changes: draftChanges, changes: draftChanges,
entrypoints: entrypoints(), entrypoints: entrypoints(),
}); });
await toolchain?.evaluate(candidate.contract); await toolchain.evaluate(candidate.contract);
diagnostics = []; diagnostics = [];
candidateContract = candidate.contract; candidateContract = candidate.contract;
preflightDigest = candidate.snapshot.digest; preflightDigest = candidate.snapshot.digest;
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`; return candidate;
} catch (error) { }
function recordCandidateError(error: unknown) {
const message = String(error); const message = String(error);
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch"); conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message; status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
}
async function preview() {
if (!treeState) return;
busy = true;
try {
await formatDraftSources();
if (draftChanges.length === 0) {
status = "No draft changes to preview.";
return;
}
const candidate = await requestCandidatePreview();
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
} catch (error) {
recordCandidateError(error);
} finally { } finally {
busy = false; busy = false;
} }
@@ -159,23 +203,24 @@
async function commit() { async function commit() {
if (!treeState) return; if (!treeState) return;
await stageCurrent(); busy = true;
try {
await formatDraftSources();
if (draftChanges.length === 0) { if (draftChanges.length === 0) {
status = "No draft changes to commit."; status = "No draft changes to commit.";
return; return;
} }
if (preflightDigest !== treeState.snapshot.digest || !candidateContract) { if (preflightDigest !== treeState.snapshot.digest || !candidateContract) {
status = "Preview the complete candidate successfully before Commit."; await requestCandidatePreview();
return;
} }
busy = true; const contract = candidateContract;
try { if (!contract) throw new Error("candidate preview did not return a toolchain contract");
treeState = await commitConfigTree(workspaceId, { treeState = await commitConfigTree(workspaceId, {
base_revision: baseRevision, base_revision: baseRevision,
base_digest: baseDigest, base_digest: baseDigest,
changes: draftChanges, changes: draftChanges,
entrypoints: candidateContract.entrypoints, entrypoints: contract.entrypoints,
toolchain_fingerprint: candidateContract.fingerprint, toolchain_fingerprint: contract.fingerprint,
}); });
draftChanges = []; draftChanges = [];
preflightDigest = ""; preflightDigest = "";
@@ -187,11 +232,9 @@
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
source = treeState.snapshot.entries[selectedPath]?.content ?? ""; source = treeState.snapshot.entries[selectedPath]?.content ?? "";
diagnostics = []; diagnostics = [];
status = `Committed revision ${treeState.snapshot.revision}.`; status = `Committed formatted revision ${treeState.snapshot.revision}.`;
} catch (error) { } catch (error) {
const message = String(error); recordCandidateError(error);
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
} finally { } finally {
busy = false; busy = false;
} }
@@ -98,3 +98,33 @@ Deno.test("main entrypoint always enables the fixed schema wrapper", async () =>
"the authoritative main source should not require an optional client-side conversion", "the authoritative main source should not require an optional client-side conversion",
); );
}); });
Deno.test("preview and commit format every draft Decodal source", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/config-source/ConfigSourceEditor.svelte",
import.meta.url,
),
);
assert(
source.includes("async function formatDraftSources()") &&
source.includes('change.kind === "create" || change.kind === "update"') &&
source.includes('change.kind === "rename"') &&
source.includes('entry.content_type !== "decodal"') &&
source.includes("await toolchain.format(entry.content)"),
"all changed Decodal entries should be formatted rather than only the selected source",
);
assert(
source.includes("await formatDraftSources();") &&
source.includes("await requestCandidatePreview();") &&
source.includes("Committed formatted revision"),
"preview and commit should format first and refresh stale preflight before persistence",
);
assert(
!source.includes(
"Preview the complete candidate successfully before Commit.",
),
"format-driven candidate changes should trigger automatic preflight instead of a dead-end error",
);
});