fix: diagnose nested partial Profile values in the editor (T-710)

Resolve MR sequence 3 profile_nested_editor_diagnostics_missing with a separate, non-materializing authoring constraint pass. Preserve omission and the persisted evaluation schema, and map nested/imported failures to supplied virtual-source spans. Exercise actual Backend schema in native and regenerated WASM tests for empty/partial values, worker and feature types, unknown fields, imports and composition.

Validation: root cargo check; config-source 38; config-source-wasm native/doc; unfiltered yoi-workspace-server 642 lib + 11 bin; worker-runtime 340 lib + 17 per binary; server-api --features typescript 77; focused Deno 39; deno task check; wasm32 release build, wasm-bindgen 0.2.117 + Binaryen 132 -Oz regeneration; cargo fmt/diff checks. Earlier checker failure did not recur: Reviewer independently passed full source suite and exact original baseline checker filter, recorded in MR sequence 3.
This commit is contained in:
2026-10-07 00:36:16 +09:00
parent 58462d5844
commit 3558e4aae1
6 changed files with 291 additions and 26 deletions
+183 -12
View File
@@ -487,7 +487,8 @@ pub struct ConfigSchemaContribution {
pub namespace: String,
pub version: String,
pub source: String,
// Optional editor-only schema; never used to validate or materialize config.
// Optional editor shape for completion and non-materializing constraint analysis;
// never used to materialize or validate the persisted config projection.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authoring_source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1250,17 +1251,83 @@ impl SnapshotEnvironment {
"configured entrypoint is missing",
)];
};
let service = LanguageService::new(self);
service
.analyze(
entrypoint.as_str(),
entrypoint.as_str(),
source_override.unwrap_or(&entry.content),
)
let source = source_override.unwrap_or(&entry.content);
let diagnostics: Vec<_> = LanguageService::new(self)
.analyze(entrypoint.as_str(), entrypoint.as_str(), source)
.diagnostics
.iter()
.map(|diagnostic| project_diagnostic(&self.snapshot, entrypoint.clone(), diagnostic))
.collect()
.collect();
if !diagnostics.is_empty()
|| !self.schema_bundle.as_ref().is_some_and(|bundle| {
bundle
.contributions
.iter()
.any(|entry| entry.authoring_source.is_some())
})
{
return diagnostics;
}
// Check authoring constraints separately, without materializing the shape.
// Partial records may omit any recipe field: authoring metadata must neither
// make those fields required nor supply defaults to the saved projection.
let environment = ConfigAuthoringEnvironment {
environment: self,
schema_path: &[],
};
let mut engine = match environment.create_engine() {
Ok(engine) => engine,
Err(diagnostic) => {
return vec![project_diagnostic(
&self.snapshot,
entrypoint.clone(),
&diagnostic,
)];
}
};
let result = engine
.add_root_source(entrypoint.as_str(), entrypoint.as_str(), source)
.and_then(|module| engine.eval_module(module));
match result {
Ok(_) => Vec::new(),
Err(mut diagnostic) => {
// Constraint failures often point at the schema declaration. Anchor
// the editor diagnostic at the narrowest supplied value instead;
// label spans have no file field, so retain only that source's labels.
if !engine
.source_name(diagnostic.span.source)
.is_some_and(|name| {
VirtualPath::parse(name)
.is_ok_and(|path| self.snapshot.get(&path).is_some())
})
&& let Some(span) = diagnostic
.labels
.iter()
.map(|label| label.span)
.filter(|span| {
engine.source_name(span.source).is_some_and(|name| {
VirtualPath::parse(name)
.is_ok_and(|path| self.snapshot.get(&path).is_some())
})
})
.min_by_key(|span| span.end.saturating_sub(span.start))
{
diagnostic.span = span;
}
diagnostic
.labels
.retain(|label| label.span.source == diagnostic.span.source);
let mut projected = project_engine_diagnostic(
&engine,
&self.snapshot,
entrypoint.clone(),
&diagnostic,
);
projected.kind = diagnostic_kind(diagnostic.kind).to_string();
vec![projected]
}
}
}
pub fn complete(
@@ -1309,7 +1376,7 @@ impl SnapshotEnvironment {
}
member_source.push('.');
member_source.push_str(prefix);
let completion_environment = ConfigFieldCompletionEnvironment {
let completion_environment = ConfigAuthoringEnvironment {
environment: self,
schema_path: if has_array { schema_path } else { &[] },
};
@@ -1379,12 +1446,12 @@ impl HostEnvironment for &SnapshotEnvironment {
// Decodal has no indexed member syntax. Resolve array ranges only in this
// completion environment, then reuse the language service's member behavior.
struct ConfigFieldCompletionEnvironment<'a> {
struct ConfigAuthoringEnvironment<'a> {
environment: &'a SnapshotEnvironment,
schema_path: &'a [ConfigCompletionPathSegment],
}
impl HostEnvironment for ConfigFieldCompletionEnvironment<'_> {
impl HostEnvironment for ConfigAuthoringEnvironment<'_> {
type Loader = SnapshotImportLoader;
fn create_loader(&self) -> Self::Loader {
@@ -2488,6 +2555,110 @@ mod tests {
assert_eq!(environment.evaluate_contract(&contract).unwrap(), evaluated);
}
#[test]
fn actual_profile_schema_diagnoses_nested_values_without_materializing_omissions() {
let schema = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
"builtin:profile",
"profile",
"2",
include_str!("../../../resources/config-schema/profile.dcdl"),
)
.unwrap()
.with_authoring_source(include_str!(
"../../../resources/config-schema/profile-authoring.dcdl"
))])
.unwrap();
let source = "{ profile = { entries = [{ selector = \"project:alpha\"; profile = {}; }]; }; } as WorkspaceConfigSchema";
let snapshot = ConfigTreeSnapshot::from_entries(
8,
[
entry("main.dcdl", source),
entry("recipe.dcdl", "{ worker = { mode = 42; }; }"),
],
)
.unwrap();
let environment = SnapshotEnvironment::new(snapshot).with_schema_bundle(schema.clone());
let contract = ToolchainContract::with_schema_bundle(1, vec![path("main.dcdl")], 1, schema);
let before = environment.evaluate_contract(&contract).unwrap();
for value in [
"{}",
"{ worker = {}; }",
"{ worker = { mode = \"wip\"; }; }",
"{ feature = { task = {}; }; }",
] {
let source = source.replace("profile = {};", &format!("profile = {value};"));
assert!(
environment
.analyze(&path("main.dcdl"), Some(&source))
.is_empty(),
"{source}"
);
}
for (value, field, expected_path) in [
("{ worker = 42; }", "worker", "main.dcdl"),
("{ worker = { mode = 42; }; }", "mode", "main.dcdl"),
(
"{ feature = { task = { enabled = 42; }; }; }",
"enabled",
"main.dcdl",
),
("{ worker = { typo = \"wip\"; }; }", "typo", "main.dcdl"),
("import \"./recipe.dcdl\"", "mode", "recipe.dcdl"),
(
"(import \"./recipe.dcdl\") // { worker = { mode = 42; }; }",
"mode",
"main.dcdl",
),
] {
let source = source.replace("profile = {};", &format!("profile = {value};"));
let diagnostics = environment.analyze(&path("main.dcdl"), Some(&source));
let diagnostic = diagnostics
.first()
.unwrap_or_else(|| panic!("missing {field} diagnostic for {source}"));
assert!(
matches!(
diagnostic.kind.as_str(),
"constraint_violation" | "type_mismatch"
),
"{diagnostic:?}"
);
assert_eq!(diagnostic.path, path(expected_path), "{diagnostic:?}");
assert_eq!(diagnostic.revision, 8);
let diagnostic_source = if expected_path == "main.dcdl" {
&source
} else {
"{ worker = { mode = 42; }; }"
};
assert!(
diagnostic.span.end_byte > diagnostic.span.start_byte
&& diagnostic.span.end_byte <= diagnostic_source.len() as u32,
"{diagnostic:?}"
);
assert!(
diagnostic.message.contains(field)
|| diagnostic
.labels
.iter()
.any(|label| label.message.contains(field)),
"{diagnostic:?}"
);
assert!(
diagnostic
.labels
.iter()
.all(|label| label.span.end_byte <= diagnostic_source.len() as u32)
);
}
// Analysis never changes the evaluation schema or transports authoring fields.
assert_eq!(environment.evaluate_contract(&contract).unwrap(), before);
assert_eq!(
before.projections[0]
.data_json
.pointer("/profile/entries/0/profile"),
Some(&serde_json::json!({}))
);
}
#[test]
fn completion_authoring_sources_compose_with_validation_source_fallbacks() {
let schema = WorkspaceConfigSchemaBundle::compose([