config: format sources before commit
This commit is contained in:
@@ -456,6 +456,40 @@ fn normalize_main_config_schema_assertion(
|
||||
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(
|
||||
current: WorkspaceConfigState,
|
||||
changes: &[ConfigTreeChange],
|
||||
@@ -465,6 +499,7 @@ fn evaluate_candidate(
|
||||
let snapshot = current.snapshot.apply(changes).map_err(config_error)?;
|
||||
ensure_main_entrypoint(&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 evaluation = SnapshotEnvironment::new(snapshot.clone())
|
||||
.evaluate_contract(&contract)
|
||||
@@ -1322,7 +1357,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 current = store.load_workspace_config("w-config").unwrap().unwrap();
|
||||
let candidate = store
|
||||
@@ -1356,7 +1391,7 @@ mod tests {
|
||||
.get(&path("module.dcdl"))
|
||||
.unwrap()
|
||||
.content,
|
||||
"{}"
|
||||
"{}\n"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -130,28 +130,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function formatDraftSources() {
|
||||
if (!toolchain || !treeState || !baseSnapshot) return;
|
||||
await stageCurrent();
|
||||
const paths = new Set<string>();
|
||||
for (const change of draftChanges) {
|
||||
if (change.kind === "create" || change.kind === "update") paths.add(change.path);
|
||||
if (change.kind === "rename") paths.add(change.to);
|
||||
}
|
||||
|
||||
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, {
|
||||
changes: draftChanges,
|
||||
entrypoints: entrypoints(),
|
||||
});
|
||||
await toolchain.evaluate(candidate.contract);
|
||||
diagnostics = [];
|
||||
candidateContract = candidate.contract;
|
||||
preflightDigest = candidate.snapshot.digest;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function recordCandidateError(error: unknown) {
|
||||
const message = String(error);
|
||||
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
if (!treeState) return;
|
||||
await stageCurrent();
|
||||
if (draftChanges.length === 0) {
|
||||
status = "No draft changes to preview.";
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const candidate = await previewConfigTree(workspaceId, {
|
||||
changes: draftChanges,
|
||||
entrypoints: entrypoints(),
|
||||
});
|
||||
await toolchain?.evaluate(candidate.contract);
|
||||
diagnostics = [];
|
||||
candidateContract = candidate.contract;
|
||||
preflightDigest = candidate.snapshot.digest;
|
||||
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) {
|
||||
const message = String(error);
|
||||
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||
recordCandidateError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
@@ -159,39 +203,38 @@
|
||||
|
||||
async function commit() {
|
||||
if (!treeState) return;
|
||||
await stageCurrent();
|
||||
if (draftChanges.length === 0) {
|
||||
status = "No draft changes to commit.";
|
||||
return;
|
||||
}
|
||||
if (preflightDigest !== treeState.snapshot.digest || !candidateContract) {
|
||||
status = "Preview the complete candidate successfully before Commit.";
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
await formatDraftSources();
|
||||
if (draftChanges.length === 0) {
|
||||
status = "No draft changes to commit.";
|
||||
return;
|
||||
}
|
||||
if (preflightDigest !== treeState.snapshot.digest || !candidateContract) {
|
||||
await requestCandidatePreview();
|
||||
}
|
||||
const contract = candidateContract;
|
||||
if (!contract) throw new Error("candidate preview did not return a toolchain contract");
|
||||
treeState = await commitConfigTree(workspaceId, {
|
||||
base_revision: baseRevision,
|
||||
base_digest: baseDigest,
|
||||
changes: draftChanges,
|
||||
entrypoints: candidateContract.entrypoints,
|
||||
toolchain_fingerprint: candidateContract.fingerprint,
|
||||
entrypoints: contract.entrypoints,
|
||||
toolchain_fingerprint: contract.fingerprint,
|
||||
});
|
||||
draftChanges = [];
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
conflict = false;
|
||||
candidateContract = null;
|
||||
conflict = false;
|
||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||
baseRevision = treeState.snapshot.revision;
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
source = treeState.snapshot.entries[selectedPath]?.content ?? "";
|
||||
diagnostics = [];
|
||||
status = `Committed revision ${treeState.snapshot.revision}.`;
|
||||
status = `Committed formatted revision ${treeState.snapshot.revision}.`;
|
||||
} catch (error) {
|
||||
const message = String(error);
|
||||
conflict = message.includes("conflict") || message.includes("base revision/digest mismatch");
|
||||
status = conflict ? `${message} Reload the authoritative tree before editing again.` : message;
|
||||
recordCandidateError(error);
|
||||
} finally {
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user