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)
|
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 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() {
|
async function preview() {
|
||||||
if (!treeState) return;
|
if (!treeState) return;
|
||||||
await stageCurrent();
|
|
||||||
if (draftChanges.length === 0) {
|
|
||||||
status = "No draft changes to preview.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const candidate = await previewConfigTree(workspaceId, {
|
await formatDraftSources();
|
||||||
changes: draftChanges,
|
if (draftChanges.length === 0) {
|
||||||
entrypoints: entrypoints(),
|
status = "No draft changes to preview.";
|
||||||
});
|
return;
|
||||||
await toolchain?.evaluate(candidate.contract);
|
}
|
||||||
diagnostics = [];
|
const candidate = await requestCandidatePreview();
|
||||||
candidateContract = candidate.contract;
|
|
||||||
preflightDigest = candidate.snapshot.digest;
|
|
||||||
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
|
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
|
||||||
} 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;
|
||||||
}
|
}
|
||||||
@@ -159,39 +203,38 @@
|
|||||||
|
|
||||||
async function commit() {
|
async function commit() {
|
||||||
if (!treeState) return;
|
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;
|
busy = true;
|
||||||
try {
|
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, {
|
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 = "";
|
||||||
candidateContract = null;
|
candidateContract = null;
|
||||||
conflict = false;
|
conflict = false;
|
||||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||||
baseRevision = treeState.snapshot.revision;
|
baseRevision = treeState.snapshot.revision;
|
||||||
baseDigest = treeState.snapshot.digest;
|
baseDigest = treeState.snapshot.digest;
|
||||||
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user