feat(profiles): seal builtin import provenance and expose read-only editor imports
Retain the evaluated JSON launch entrypoint, seal observed builtin source bytes and digests as verified provenance, and fail on missing/corrupt dependencies without Runtime builtin reevaluation. Cover real HTTP commits and launch resolution, including worker mode and Workspace Config feature composition. Expose read-only builtin import completions/diagnostics in the editor and rebuild wasm32 release assets with wasm-bindgen 0.2.117. Document public paths, ordinary composition, limits, resave semantics, and the lazy-cycle incident. Validation: worker-runtime 344 library + 34 integration tests; workspace-server 609 library + 11 binary tests plus final builtin HTTP/launch tests (2); archive tests (16); native WASM tests (2); real-WASM/web tests (37), editor component tests (10); root cargo check, cargo fmt --all -- --check and git diff --check HEAD pass.
This commit is contained in:
@@ -98,14 +98,31 @@ pub fn complete_current(
|
||||
let snapshot = session
|
||||
.as_ref()
|
||||
.ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?;
|
||||
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
|
||||
encode(complete_snapshot(
|
||||
snapshot,
|
||||
&entrypoint,
|
||||
&source,
|
||||
utf16_offset,
|
||||
explicit,
|
||||
)?)
|
||||
})
|
||||
}
|
||||
|
||||
fn complete_snapshot(
|
||||
snapshot: &ConfigTreeSnapshot,
|
||||
entrypoint: &VirtualPath,
|
||||
source: &str,
|
||||
utf16_offset: usize,
|
||||
explicit: bool,
|
||||
) -> Result<Option<WasmCompletionResult>, JsValue> {
|
||||
let utf8_byte_offset = utf16_to_utf8_offset(source, utf16_offset)?;
|
||||
let result = session_environment(snapshot.clone())
|
||||
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
|
||||
.complete_config(entrypoint, source, utf8_byte_offset, explicit)
|
||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
|
||||
let result = result
|
||||
.map(|result| {
|
||||
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
|
||||
from: utf8_to_utf16_offset(&source, result.from)?,
|
||||
from: utf8_to_utf16_offset(source, result.from)?,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
@@ -119,8 +136,7 @@ pub fn complete_current(
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
encode(result)
|
||||
})
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
@@ -207,3 +223,98 @@ fn js_error(error: impl std::fmt::Display) -> JsValue {
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _assert_serializable(_: EvaluationResult) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use config_source::{ConfigContentType, ConfigEntry};
|
||||
|
||||
fn snapshot() -> ConfigTreeSnapshot {
|
||||
ConfigTreeSnapshot::from_entries(
|
||||
7,
|
||||
[
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("main.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap(),
|
||||
// A same-suffix workspace file must not be an unknown builtin fallback.
|
||||
ConfigEntry::new(
|
||||
VirtualPath::parse("profiles/missing.dcdl").unwrap(),
|
||||
ConfigContentType::Decodal,
|
||||
"{}",
|
||||
)
|
||||
.unwrap(),
|
||||
],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_completion_boundary_preserves_read_only_builtin_detail() {
|
||||
let snapshot = snapshot();
|
||||
let source = "let 名 = 1; import \"$builtin/profiles/comp\"";
|
||||
let cursor = source.encode_utf16().count() - 1;
|
||||
let result = complete_snapshot(
|
||||
&snapshot,
|
||||
&VirtualPath::parse("main.dcdl").unwrap(),
|
||||
source,
|
||||
cursor,
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(result.from, "let 名 = 1; import \"".encode_utf16().count());
|
||||
let item = result
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.label == "$builtin/profiles/companion.dcdl")
|
||||
.unwrap();
|
||||
assert_eq!(item.kind, "file");
|
||||
assert_eq!(
|
||||
item.detail.as_deref(),
|
||||
Some("read-only builtin Decodal source")
|
||||
);
|
||||
assert_eq!(snapshot.entries.len(), 2);
|
||||
assert!(
|
||||
snapshot
|
||||
.entries
|
||||
.keys()
|
||||
.all(|path| !path.as_str().starts_with("$builtin/"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_analysis_boundary_reports_unknown_builtin_without_workspace_fallback() {
|
||||
let snapshot = snapshot();
|
||||
let path = VirtualPath::parse("main.dcdl").unwrap();
|
||||
let diagnostics = session_environment(snapshot.clone())
|
||||
.analyze(&path, Some("import \"$builtin/profiles/missing.dcdl\""));
|
||||
assert!(!diagnostics.is_empty());
|
||||
let diagnostic = diagnostics
|
||||
.iter()
|
||||
.find(|diagnostic| {
|
||||
diagnostic
|
||||
.message
|
||||
.contains("unknown or non-public read-only builtin source")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(diagnostic.path, path);
|
||||
assert_eq!(diagnostic.revision, 7);
|
||||
assert_eq!(diagnostic.tree_digest, snapshot.digest);
|
||||
assert!(
|
||||
diagnostic
|
||||
.message
|
||||
.contains("$builtin/profiles/missing.dcdl")
|
||||
);
|
||||
assert!(
|
||||
session_environment(snapshot)
|
||||
.analyze(
|
||||
&VirtualPath::parse("main.dcdl").unwrap(),
|
||||
Some("import \"./profiles/missing.dcdl\"")
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,15 +134,31 @@ impl ProfileSourceArchive {
|
||||
selector: String,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Self, ProfileArchiveError> {
|
||||
Self::build_evaluated_profile_with_builtin_sources(id, selector, value, BTreeMap::new())
|
||||
}
|
||||
|
||||
/// Seal the observed builtin import closure as provenance alongside the evaluated JSON.
|
||||
/// Builtin sources are verified, but never evaluated to resolve this entrypoint.
|
||||
pub fn build_evaluated_profile_with_builtin_sources(
|
||||
id: String,
|
||||
selector: String,
|
||||
value: serde_json::Value,
|
||||
sources: BTreeMap<String, String>,
|
||||
) -> Result<Self, ProfileArchiveError> {
|
||||
for key in sources.keys() {
|
||||
validate_builtin_source_key(key)?;
|
||||
}
|
||||
let path = "profiles/evaluated.json".to_string();
|
||||
let source = serde_json::to_string(&value)
|
||||
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?;
|
||||
let mut sources = sources;
|
||||
sources.insert(path.clone(), source);
|
||||
Self::build_with_source_format(
|
||||
ProfileSourceArchiveInput {
|
||||
id,
|
||||
entrypoints: BTreeMap::from([(selector, path.clone())]),
|
||||
entrypoints: BTreeMap::from([(selector, path)]),
|
||||
imports: BTreeMap::new(),
|
||||
sources: BTreeMap::from([(path, source)]),
|
||||
sources,
|
||||
},
|
||||
"json",
|
||||
"application/json",
|
||||
@@ -171,6 +187,12 @@ impl ProfileSourceArchive {
|
||||
if total_source_bytes > MAX_TOTAL_BYTES {
|
||||
return Err(ProfileArchiveError::LimitExceeded("total source bytes"));
|
||||
}
|
||||
let (kind, content_type) = if path.starts_with("$builtin/") {
|
||||
validate_builtin_source_key(path)?;
|
||||
("decodal", "text/x-decodal")
|
||||
} else {
|
||||
(kind, content_type)
|
||||
};
|
||||
source_meta.push(ProfileSourceArchiveSource {
|
||||
path: path.clone(),
|
||||
source_key: path.clone(),
|
||||
@@ -310,7 +332,18 @@ impl VerifiedProfileSourceArchive {
|
||||
let mut manifest_paths = BTreeSet::new();
|
||||
for source in &manifest.sources {
|
||||
validate_archive_path(&source.path)?;
|
||||
if !source.source_key.is_empty() && source.source_key != source.path {
|
||||
if source.path.starts_with("$builtin/") {
|
||||
validate_builtin_source_key(&source.path)?;
|
||||
if source.kind != "decodal" {
|
||||
return Err(ProfileArchiveError::UnsupportedSource {
|
||||
path: source.path.clone(),
|
||||
kind: source.kind.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (source.path.starts_with("$builtin/") || !source.source_key.is_empty())
|
||||
&& source.source_key != source.path
|
||||
{
|
||||
return Err(ProfileArchiveError::ImportMapMismatch {
|
||||
specifier: source.path.clone(),
|
||||
expected: source.path.clone(),
|
||||
@@ -662,6 +695,26 @@ fn append_bytes<W: Write>(
|
||||
.map_err(|err| ProfileArchiveError::Io(err.to_string()))
|
||||
}
|
||||
|
||||
fn validate_builtin_source_key(key: &str) -> Result<(), ProfileArchiveError> {
|
||||
validate_archive_path(key)?;
|
||||
let Some(relative) = key.strip_prefix("$builtin/profiles/") else {
|
||||
return Err(ProfileArchiveError::UnsafePath(key.to_string()));
|
||||
};
|
||||
if !relative.ends_with(".dcdl")
|
||||
|| relative.split('/').any(|part| {
|
||||
part.is_empty()
|
||||
|| part == "."
|
||||
|| part == ".."
|
||||
|| !part
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
|
||||
})
|
||||
{
|
||||
return Err(ProfileArchiveError::UnsafePath(key.to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_archive_path(path: &str) -> Result<(), ProfileArchiveError> {
|
||||
if path.is_empty() || path == MANIFEST_PATH {
|
||||
return if path == MANIFEST_PATH {
|
||||
@@ -799,6 +852,155 @@ mod tests {
|
||||
archive.content = content;
|
||||
}
|
||||
|
||||
fn builtin_provenance_archive() -> ProfileSourceArchive {
|
||||
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
|
||||
"builtin-provenance".into(),
|
||||
"project:evaluated".into(),
|
||||
evaluated_profile(),
|
||||
BTreeMap::from([
|
||||
(
|
||||
"$builtin/profiles/companion.dcdl".into(),
|
||||
"import \"./base.dcdl\"".into(),
|
||||
),
|
||||
// Intentionally invalid Decodal: provenance must not be reevaluated.
|
||||
(
|
||||
"$builtin/profiles/base.dcdl".into(),
|
||||
"not valid Decodal !".into(),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_seals_builtin_closure_without_runtime_evaluation() {
|
||||
let archive = builtin_provenance_archive();
|
||||
let verified = archive.verify().unwrap();
|
||||
assert_eq!(
|
||||
archive,
|
||||
builtin_provenance_archive(),
|
||||
"archive must be deterministic"
|
||||
);
|
||||
assert_eq!(archive.reference.source_graph.source_count, 3);
|
||||
assert!(verified.manifest.imports.is_empty());
|
||||
assert_eq!(
|
||||
verified.manifest.entrypoints["project:evaluated"],
|
||||
"profiles/evaluated.json"
|
||||
);
|
||||
for source in &verified.manifest.sources {
|
||||
assert_eq!(source.source_key, source.path);
|
||||
assert_eq!(
|
||||
source.digest,
|
||||
sha256_hex(verified.sources[&source.path].as_bytes())
|
||||
);
|
||||
if source.path.starts_with("$builtin/") {
|
||||
assert_eq!(source.kind, "decodal");
|
||||
assert_eq!(source.content_type, "text/x-decodal");
|
||||
} else {
|
||||
assert_eq!(source.kind, "json");
|
||||
}
|
||||
}
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let resolved = verified
|
||||
.resolve_profile("project:evaluated", root.path(), "sealed-worker")
|
||||
.unwrap();
|
||||
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
|
||||
assert!(resolved.feature.task.enabled);
|
||||
assert!(matches!(
|
||||
verified.resolve_profile("builtin:companion", root.path(), "sealed-worker"),
|
||||
Err(ProfileArchiveError::MissingEntrypoint(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_rejects_missing_or_corrupt_builtin_dependency() {
|
||||
for missing in [true, false] {
|
||||
let mut archive = builtin_provenance_archive();
|
||||
let mut verified = archive.verify().unwrap();
|
||||
let path = "$builtin/profiles/base.dcdl";
|
||||
if missing {
|
||||
verified.sources.remove(path);
|
||||
} else {
|
||||
verified
|
||||
.sources
|
||||
.insert(path.into(), "corrupt dependency".into());
|
||||
}
|
||||
repack_archive(&mut archive, &verified.manifest, &verified.sources);
|
||||
match archive.verify().unwrap_err() {
|
||||
ProfileArchiveError::MissingSource(actual) if missing => assert_eq!(actual, path),
|
||||
ProfileArchiveError::SourceDigestMismatch { path: actual, .. } if !missing => {
|
||||
assert_eq!(actual, path)
|
||||
}
|
||||
other => panic!("unexpected error: {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_builtin_provenance_rejects_unsafe_keys_and_reuses_limits() {
|
||||
for key in [
|
||||
"profiles/companion.dcdl",
|
||||
"builtin:profiles/companion.dcdl",
|
||||
"$builtin/prompts/companion.dcdl",
|
||||
"$builtin/profiles/../escape.dcdl",
|
||||
"$builtin/profiles/./base.dcdl",
|
||||
"$builtin/profiles//base.dcdl",
|
||||
"$builtin/profiles/base\\other.dcdl",
|
||||
"$builtin/profiles/base\0.dcdl",
|
||||
"/$builtin/profiles/base.dcdl",
|
||||
"$builtin/profiles/base.json",
|
||||
"profiles/evaluated.json",
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
|
||||
"unsafe".into(),
|
||||
"project:evaluated".into(),
|
||||
evaluated_profile(),
|
||||
BTreeMap::from([(key.into(), "{}".into())]),
|
||||
),
|
||||
Err(ProfileArchiveError::UnsafePath(_))
|
||||
),
|
||||
"{key:?}"
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
|
||||
"oversized".into(),
|
||||
"project:evaluated".into(),
|
||||
evaluated_profile(),
|
||||
BTreeMap::from([(
|
||||
"$builtin/profiles/base.dcdl".into(),
|
||||
"x".repeat(MAX_SOURCE_BYTES as usize + 1)
|
||||
)]),
|
||||
),
|
||||
Err(ProfileArchiveError::LimitExceeded("source bytes"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_builtin_provenance_requires_canonical_key_and_source_format() {
|
||||
for key in ["", "profiles/base.dcdl", "$builtin/profiles/other.dcdl"] {
|
||||
let mut archive = builtin_provenance_archive();
|
||||
let mut verified = archive.verify().unwrap();
|
||||
verified.manifest.sources[0].source_key = key.into();
|
||||
repack_archive(&mut archive, &verified.manifest, &verified.sources);
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::ImportMapMismatch { .. })
|
||||
));
|
||||
}
|
||||
let mut archive = builtin_provenance_archive();
|
||||
let mut verified = archive.verify().unwrap();
|
||||
verified.manifest.sources[0].kind = "json".into();
|
||||
verified.manifest.sources[0].content_type = "application/json".into();
|
||||
repack_archive(&mut archive, &verified.manifest, &verified.sources);
|
||||
assert!(matches!(
|
||||
archive.verify(),
|
||||
Err(ProfileArchiveError::UnsupportedSource { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluated_json_reuses_source_size_limit() {
|
||||
assert!(matches!(
|
||||
|
||||
@@ -464,10 +464,20 @@ fn build_virtual_profile_archive(
|
||||
entry: &VirtualProfileEntry,
|
||||
state: &WorkspaceConfigState,
|
||||
) -> Result<ProfileSourceArchive> {
|
||||
ProfileSourceArchive::build_evaluated_profile(
|
||||
let builtin_sources = config_source::SnapshotEnvironment::new(state.snapshot.clone())
|
||||
.builtin_import_sources(&state.contract)
|
||||
.map_err(|diagnostics| {
|
||||
profile_validation_error(
|
||||
"profile_builtin_snapshot_invalid",
|
||||
&serde_json::to_string(&diagnostics)
|
||||
.unwrap_or_else(|_| "builtin import snapshot failed".to_string()),
|
||||
)
|
||||
})?;
|
||||
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
|
||||
format!("workspace-config-profile-r{}", state.snapshot.revision),
|
||||
selector.to_string(),
|
||||
entry.profile.clone(),
|
||||
builtin_sources,
|
||||
)
|
||||
.map_err(|error| profile_validation_error("profile_value_archive_invalid", &error.to_string()))
|
||||
}
|
||||
|
||||
@@ -44,6 +44,287 @@ fn source(value: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
||||
let app = build_router(api.clone());
|
||||
let tree = format!("/api/w/{TEST_WORKSPACE_ID}/config/source-tree");
|
||||
let commit = format!("{tree}/commit");
|
||||
let initial = get_json_authenticated(app.clone(), &tree, &token).await;
|
||||
let saved = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&commit,
|
||||
Some(
|
||||
serde_json::to_value(request(
|
||||
&api,
|
||||
&source(r#"import "$builtin/profiles/companion.dcdl""#),
|
||||
vec![config_source::ConfigTreeChange::Create {
|
||||
// An ordinary Workspace file at the matching suffix must not rescue
|
||||
// an unknown builtin import.
|
||||
path: config_source::VirtualPath::parse("profiles/private.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: PROFILE.into(),
|
||||
}],
|
||||
))
|
||||
.unwrap(),
|
||||
),
|
||||
&token,
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
saved["snapshot"]["revision"].as_u64().unwrap(),
|
||||
initial["snapshot"]["revision"].as_u64().unwrap() + 1
|
||||
);
|
||||
let listed = get_json_authenticated(app.clone(), &tree, &token).await;
|
||||
assert_eq!(listed, saved);
|
||||
let entries = listed["snapshot"]["entries"].as_object().unwrap();
|
||||
assert!(entries.contains_key("profiles/private.dcdl"));
|
||||
assert!(
|
||||
entries
|
||||
.keys()
|
||||
.all(|key| key != "$builtin" && !key.starts_with("$builtin/"))
|
||||
);
|
||||
let shadow = get_json_authenticated(
|
||||
app.clone(),
|
||||
&format!("{tree}/entries/profiles%2Fprivate.dcdl"),
|
||||
&token,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(shadow, entries["profiles/private.dcdl"]);
|
||||
assert!(
|
||||
shadow["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("slug = \"alpha\"")
|
||||
);
|
||||
let active = api
|
||||
.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let main_digest = entries["main.dcdl"]["content_digest"].as_str().unwrap();
|
||||
let base = json!({
|
||||
"base_revision": listed["snapshot"]["revision"],
|
||||
"base_digest": listed["snapshot"]["digest"],
|
||||
"entrypoints": listed["contract"]["entrypoints"],
|
||||
});
|
||||
let reserved = "$builtin/profiles/companion.dcdl";
|
||||
for change in [
|
||||
json!({ "kind": "create", "path": reserved, "content_type": "decodal", "content": PROFILE }),
|
||||
json!({ "kind": "create", "path": "$builtin", "content_type": "text", "content": "shadow" }),
|
||||
json!({ "kind": "update", "path": reserved, "expected_digest": "not-a-workspace-entry", "content": PROFILE }),
|
||||
json!({ "kind": "delete", "path": reserved, "expected_digest": "not-a-workspace-entry" }),
|
||||
json!({ "kind": "rename", "from": "profiles/private.dcdl", "to": reserved, "expected_digest": shadow["content_digest"] }),
|
||||
json!({ "kind": "rename", "from": reserved, "to": "profiles/copy.dcdl", "expected_digest": "not-a-workspace-entry" }),
|
||||
] {
|
||||
let mut body = base.clone();
|
||||
body["changes"] = json!([change]);
|
||||
let error = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&commit,
|
||||
Some(body),
|
||||
&token,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
error["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("builtin namespace is read-only"),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(
|
||||
get_json_authenticated(app.clone(), &tree, &token).await,
|
||||
listed
|
||||
);
|
||||
}
|
||||
let mut body = base;
|
||||
body["changes"] = json!([{
|
||||
"kind": "update", "path": "main.dcdl", "expected_digest": main_digest,
|
||||
"content": source(r#"import "$builtin/profiles/private.dcdl""#),
|
||||
}]);
|
||||
let error = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&commit,
|
||||
Some(body),
|
||||
&token,
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
.await;
|
||||
let message = error["message"].as_str().unwrap();
|
||||
assert!(
|
||||
message.contains(
|
||||
"unknown or non-public read-only builtin source: $builtin/profiles/private.dcdl"
|
||||
),
|
||||
"{error}"
|
||||
);
|
||||
assert!(message.contains("\"kind\":\"import\""), "{error}");
|
||||
assert_eq!(get_json_authenticated(app, &tree, &token).await, listed);
|
||||
assert_eq!(
|
||||
api.config_store
|
||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
active
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_builtin_companion_composition_seals_observed_sources() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let recipe = r#"(import "$builtin/profiles/companion.dcdl") // {
|
||||
worker = { mode = "wip"; };
|
||||
feature = {
|
||||
task = { enabled = true; };
|
||||
ticket = { enabled = false; };
|
||||
workspace_config = { enabled = true; };
|
||||
};
|
||||
}"#;
|
||||
let state = commit_workspace_config_tree(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&request(
|
||||
&api,
|
||||
&source(r#"import "./recipes/custom.dcdl""#),
|
||||
vec![
|
||||
config_source::ConfigTreeChange::Create {
|
||||
path: config_source::VirtualPath::parse("recipes/custom.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: recipe.into(),
|
||||
},
|
||||
// Builtin relative imports must not be shadowed by Workspace files.
|
||||
config_source::ConfigTreeChange::Create {
|
||||
path: config_source::VirtualPath::parse("profiles/base.dcdl").unwrap(),
|
||||
content_type: config_source::ConfigContentType::Decodal,
|
||||
content: "{ slug = \"workspace-shadow\"; }".into(),
|
||||
},
|
||||
],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &state).unwrap();
|
||||
let bundle = build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&state,
|
||||
TEST_WORKSPACE_ID,
|
||||
"created",
|
||||
"project:alpha",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(bundle.metadata.digest, bundle.computed_digest());
|
||||
let archive = bundle.profile_source_archive.as_ref().unwrap();
|
||||
let verified = archive.verify().unwrap();
|
||||
let environment = config_source::SnapshotEnvironment::new(state.snapshot.clone());
|
||||
let builtin_sources = environment.builtin_import_sources(&state.contract).unwrap();
|
||||
assert_eq!(
|
||||
builtin_sources
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"$builtin/profiles/base.dcdl",
|
||||
"$builtin/profiles/companion.dcdl",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
archive.reference.source_graph.source_count,
|
||||
builtin_sources.len() + 1
|
||||
);
|
||||
assert_eq!(archive.reference.source_graph.import_count, 0);
|
||||
assert_eq!(
|
||||
verified.manifest().entrypoints["project:alpha"],
|
||||
"profiles/evaluated.json"
|
||||
);
|
||||
for (key, bytes) in &builtin_sources {
|
||||
let source = verified
|
||||
.manifest()
|
||||
.sources
|
||||
.iter()
|
||||
.find(|source| &source.source_key == key)
|
||||
.unwrap();
|
||||
assert_eq!(source.path, *key);
|
||||
assert_eq!(source.kind, "decodal");
|
||||
assert_eq!(
|
||||
source.digest,
|
||||
worker_runtime::profile_archive::sha256_hex(bytes.as_bytes())
|
||||
);
|
||||
assert_eq!(source.size_bytes, bytes.len() as u64);
|
||||
}
|
||||
assert!(
|
||||
verified
|
||||
.manifest()
|
||||
.sources
|
||||
.iter()
|
||||
.any(|source| source.path == "profiles/evaluated.json" && source.kind == "json")
|
||||
);
|
||||
// Resolve using the Runtime archive consumer, without access to the config tree.
|
||||
let resolved = verified
|
||||
.resolve_profile(
|
||||
"project:alpha",
|
||||
Path::new("/runtime-worker"),
|
||||
"builtin-value-worker",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
|
||||
assert!(resolved.feature.task.enabled);
|
||||
assert!(!resolved.feature.ticket.enabled);
|
||||
assert!(resolved.feature.workspace_config.enabled);
|
||||
let evaluation = environment.evaluate_contract(&state.contract).unwrap();
|
||||
assert_eq!(
|
||||
resolved.model.ref_.as_deref(),
|
||||
evaluation.projections[0]
|
||||
.data_json
|
||||
.pointer("/profile/entries/0/profile/model/ref")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
);
|
||||
let persisted = manifest::write_persisted_worker_manifest_snapshot(&resolved).unwrap();
|
||||
let old_bundle_digest = bundle.metadata.digest.clone();
|
||||
|
||||
let updated = commit_workspace_config_tree(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
&request(&api, &source(PROFILE), vec![]),
|
||||
)
|
||||
.unwrap();
|
||||
let projection = project_profiles_from_workspace_config(TEST_WORKSPACE_ID, &updated).unwrap();
|
||||
let updated_bundle = build_virtual_profile_config_bundle(
|
||||
&projection,
|
||||
&updated,
|
||||
TEST_WORKSPACE_ID,
|
||||
"created",
|
||||
"project:alpha",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_ne!(old_bundle_digest, updated_bundle.metadata.digest);
|
||||
assert_eq!(
|
||||
updated_bundle
|
||||
.profile_source_archive
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.reference
|
||||
.source_graph
|
||||
.source_count,
|
||||
1
|
||||
);
|
||||
// Editing config changes future snapshots, not a sealed bundle or persisted Worker.
|
||||
assert_eq!(bundle.metadata.digest, old_bundle_digest);
|
||||
assert_eq!(
|
||||
serde_json::to_value(manifest::read_persisted_worker_manifest_snapshot(persisted).unwrap())
|
||||
.unwrap(),
|
||||
serde_json::to_value(resolved).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_profiles_save_project_and_runtime_consume_the_same_revision() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -39,7 +39,24 @@ Register the Profile **value**, not a source pathname:
|
||||
|
||||
The imported file can contain `{ worker = { mode = "wip"; }; model = { ref = "codex-oauth/gpt-5.6-sol"; }; feature = { task = { enabled = true; }; }; }`. That record can also be written directly at `entries[0].profile`. Composition uses ordinary Decodal expressions, for example `(import "./profiles/base.dcdl") // { worker = { mode = "wip"; }; }` for an explicit patch, or `&` for compatible refinement. No dedicated Profile file or `profiles/` directory is required. Imports are relative to the **containing virtual config file**, not a host filesystem path; missing imports and virtual-tree escapes are rejected.
|
||||
|
||||
`selector` must be a nonempty `project:*` selector, unique in the Workspace. `label` and `description` are optional display metadata. An omitted `default_profile` selects `builtin:companion`; an explicitly unknown default is an error, not a fallback. A project Profile is a partial recipe resolved against `WorkerManifestConfig::resolution_defaults()`, **not** implicitly inherited from a builtin role. Explicit values remain explicit and omitted Profile fields remain absent in the transported value. Builtin-value import helpers are not part of this registration contract.
|
||||
`selector` must be a nonempty `project:*` selector, unique in the Workspace. `label` and `description` are optional display metadata. An omitted `default_profile` selects `builtin:companion`; an explicitly unknown default is an error, not a fallback. A project Profile is a partial recipe resolved against `WorkerManifestConfig::resolution_defaults()`, **not** implicitly inherited from a builtin role. Explicit values remain explicit and omitted Profile fields remain absent in the transported value. To inherit a builtin recipe, import its public value explicitly; there is no implicit builtin inheritance.
|
||||
|
||||
### Public builtin imports
|
||||
|
||||
Workspace Decodal can import embedded Profile values through the read-only `$builtin/` namespace:
|
||||
|
||||
```dcdl
|
||||
(import "$builtin/profiles/companion.dcdl") // {
|
||||
worker = { mode = "wip"; };
|
||||
feature = { task = { enabled = true; }; ticket = { enabled = false; }; };
|
||||
}
|
||||
```
|
||||
|
||||
Use this expression anywhere a Profile value is accepted, including `profile.entries[*].profile` or an imported Workspace recipe. `$builtin/profiles/companion.dcdl` is a source path, not the launch selector `builtin:companion`. Public paths under `$builtin/profiles/` are `base.dcdl`, `default.dcdl`, `standalone.dcdl`, `standalone-subjektiv.dcdl`, `standalone-subjektiv-consolidation.dcdl`, `coder.dcdl`, `companion.dcdl`, `intake.dcdl`, `reviewer.dcdl`, `orchestrator.dcdl`, `job.dcdl`, `backend-job.dcdl`, `memory-consolidation.dcdl`, and `subjektiv-memory-consolidation.dcdl`. This is an explicit embedded catalog, not arbitrary resource or filesystem access. Unknown paths, namespace escapes, host paths and network URLs are errors.
|
||||
|
||||
Relative imports **inside a builtin source** resolve against its builtin virtual directory: companion's `./base.dcdl` means `$builtin/profiles/base.dcdl`, never the Workspace's `profiles/base.dcdl`. Relative imports in Workspace files continue to use their containing Workspace virtual directory. Builtin values obey ordinary Decodal composition: `//` applies an explicit patch and `&` refines compatible values (conflicting explicit values remain errors). Importing a builtin does not add a special merge or fallback mechanism.
|
||||
|
||||
Builtin dependencies share the Workspace snapshot budgets: at most 256 entries across Workspace entries and distinct observed builtin sources, 256 KiB per source, and 4 MiB of total source bytes. Unused Workspace entries still count; repeated imports of the same builtin count once. The observed import graph must be acyclic and at most 32 edges deep, **including the synthetic evaluation wrapper's import of the entrypoint**. These guards apply before the loader returns sources to Decodal, including imports in lazy object fields; builtins do not bypass them. Launch archives retain their separate transport limits (64 sources, 256 KiB per source, 1 MiB total source bytes).
|
||||
|
||||
The former `entries = [{ selector = "project:name"; source = "profiles/name.dcdl"; }]` registration is no longer accepted. Migrate it explicitly to `profile = import "./profiles/name.dcdl";`, preserving the selector, label, description and default. For registrations in a nested config file, adjust the relative import to the original virtual-tree entry (for example `../profiles/name.dcdl`). Do not rename or recreate the Profile file merely for registration. Previously saved source-based revisions remain readable as config data and are not rewritten, deleted, or silently substituted with a builtin. Their Profile projection/launch reports `profile_source_registration_removed` with the replacement form; saves under the new schema reject the old field. Existing Workers continue using their saved Manifest while the author migrates and saves the config.
|
||||
|
||||
@@ -57,7 +74,7 @@ Source/partial layers may omit fields. Resolved manifests should be explicit eno
|
||||
|
||||
`--manifest <path>` exists as an explicit low-level escape hatch. Normal fresh startup selects a `builtin:*` or `project:*` Profile from the Backend-managed Workspace Config revision rather than applying an ambient manifest cascade.
|
||||
|
||||
Project Profiles are evaluated as part of the revisioned Virtual Config, including its imports and value composition. The Backend packages the **evaluated selected value** into a digest-bound JSON entry in the existing Profile archive transport, and binds the launch bundle to the Workspace identity, revision, tree/projection digests and schema/toolchain fingerprints. Runtime verifies the archive and resolves that exact value without re-evaluating Decodal, scanning imports or reading Workspace files. Builtin Profiles retain their embedded Decodal archive path. Saving config changes only future fresh launches; the Worker persists the resulting resolved Manifest and restore uses that saved snapshot rather than selecting the current recipe again. Files below the Workdir are not implicit Profile override layers.
|
||||
Project Profiles are evaluated as part of the revisioned Virtual Config, including its imports and value composition. The Backend packages the **evaluated selected value** into a digest-bound JSON entry in the existing Profile archive transport, and binds the launch bundle to the Workspace identity, revision, tree/projection digests and schema/toolchain fingerprints. Runtime verifies the archive and resolves that exact value without re-evaluating Decodal, scanning imports or reading Workspace files. When Workspace evaluation imports builtins, the archive additionally seals the exact observed transitive builtin closure for the Workspace contract: canonical `$builtin/profiles/...` source keys, original Decodal bytes, content types, sizes and source digests, all bound by the outer archive and launch bundle digests. These are provenance dependencies, not runtime entrypoints or runtime import instructions. Missing or corrupt provenance fails verification even though the entrypoint is JSON; no local file or current embedded builtin can rescue it. Builtin Profiles selected directly retain their embedded Decodal archive path. Changing an embedded builtin so that it changes the evaluated Workspace value requires an **explicit config save/re-evaluation before a fresh launch**. The Backend compares current evaluation with the saved projection digest and rejects a mismatch rather than silently adopting the new value. T-711 retains the existing version-2 toolchain contract/fingerprint tuple so existing stored contracts remain readable; builtin provenance is bound by the archive and bundle digests, not a new catalog fingerprint field. Neither a builtin update nor a config save rewrites a sealed archive or silently rebases an existing Worker. Saving config changes only future fresh launches; the Worker persists the resulting resolved Manifest and restore uses that saved snapshot rather than selecting the current recipe again. Files below the Workdir are not implicit Profile override layers.
|
||||
|
||||
## Local stdio MCP server declarations
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# T-711: nested lazy-object import cycle
|
||||
|
||||
Date: 2026-10-06
|
||||
Status: fixed at the snapshot import-loader boundary; regression coverage retained.
|
||||
|
||||
## Dogfooding problem
|
||||
|
||||
During T-711 builtin-import validation, a nested import cycle in a lazy object caused a stack overflow instead of a recoverable configuration diagnostic. A configuration author could therefore terminate evaluation while writing an invalid recipe, rather than receive an error and keep the active revision. This report records the implementation-session failure and the guard added by the parent config-source implementation; it is not a recommendation to rerun the crashing version inside a live Yoi Worker.
|
||||
|
||||
## Minimal reproducer
|
||||
|
||||
Build a `ConfigTreeSnapshot` containing these two Decodal entries:
|
||||
|
||||
`main.dcdl`:
|
||||
|
||||
```dcdl
|
||||
{
|
||||
builtin = import "$builtin/profiles/companion.dcdl";
|
||||
cycle = import "./cycle.dcdl";
|
||||
}
|
||||
```
|
||||
|
||||
`cycle.dcdl`:
|
||||
|
||||
```dcdl
|
||||
import "./main.dcdl"
|
||||
```
|
||||
|
||||
Evaluate the snapshot with `SnapshotEnvironment::evaluate_contract` and a `ToolchainContract` whose entrypoint is `main.dcdl`. This fixture intentionally uses the plain evaluation contract rather than `WorkspaceConfigSchema`: it isolates import/materialization behavior, not Profile registration semantics.
|
||||
|
||||
Before the loader guard, evaluating the root object could defer its `cycle` field until materialization. Importing the root again through `cycle.dcdl` then recursed through that lazy value instead of returning a structured cycle failure. The observed failure was a process stack overflow. Increasing the stack would only postpone the failure and would not provide a safe configuration boundary.
|
||||
|
||||
## Fix
|
||||
|
||||
`SnapshotImportLoader::load` now records canonical source-to-source edges and validates the observed graph **before returning a source to Decodal**. Content-digest cache suffixes are stripped for graph identity, so the same virtual source cannot evade cycle detection by appearing under a cache key.
|
||||
|
||||
- A back edge returns a `cycle` diagnostic before lazy materialization can recurse.
|
||||
- An import chain longer than 32 edges returns an import-depth limit diagnostic. The synthetic evaluation wrapper's entrypoint import counts toward that limit.
|
||||
- Memoized dependency depths keep shared-subgraph validation bounded without losing the longest path.
|
||||
- Rejected edges are removed from the successful-edge cache. A reused language-service loader must reject the same bad edge again, not treat it as already validated.
|
||||
- Workspace and builtin imports use the same graph guard and shared source count/byte budgets. Embedded sources are not an exception.
|
||||
|
||||
The fix belongs in `crates/config-source/src/lib.rs`; it does not require runtime Decodal evaluation. Project launch archives still transport the evaluated JSON entrypoint with builtin source provenance, and Runtime resolves only that evaluated value.
|
||||
|
||||
## Regression evidence
|
||||
|
||||
The following tests in `crates/config-source/src/lib.rs` cover the incident and adjacent guard behavior:
|
||||
|
||||
- `builtin_values_do_not_bypass_cycle_or_path_limits`: the two-entry lazy-object fixture above returns `kind = "cycle"`; builtin imports do not bypass depth/path bounds.
|
||||
- `rejected_import_graph_edges_stay_rejected_after_diagnostics`: repeated attempts to add the rejected back edge remain errors when the loader is reused.
|
||||
- `import_graph_shared_dependencies_are_memoized_without_losing_depth`: shared dependencies preserve longest-path validation and the allowed depth boundary.
|
||||
- `snapshot_import_depth_is_checked_before_evaluation_recurses`: a real source chain fails with an import-depth diagnostic before evaluation recurses beyond the guard.
|
||||
- `builtin_dependencies_share_snapshot_count_and_byte_budgets`: builtin dependencies cannot exceed the aggregate entry-count or byte budget by living outside the authored tree.
|
||||
|
||||
Run individual regressions with the normal narrow target, for example:
|
||||
|
||||
```sh
|
||||
cargo test -p config-source --lib builtin_values_do_not_bypass_cycle_or_path_limits
|
||||
cargo test -p config-source --lib snapshot_import_depth_is_checked_before_evaluation_recurses
|
||||
```
|
||||
|
||||
The server follow-up test `value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back` separately exercises authenticated HTTP commits: reserved builtin mutations and unknown imports are rejected without changing the active revision, even when a Workspace file exists at the unknown builtin's suffix. Editor import completions remain covered by the WASM editor tests; no server completion endpoint was introduced.
|
||||
|
||||
## Operational lesson
|
||||
|
||||
An evaluator's module cache is not sufficient evidence that lazy-value cycles are safe. Keep cycle and depth validation at the source-loader boundary and retain the lazy-object reproducer whenever import identity, caching, or materialization changes. Config errors should be recoverable diagnostics, never an implicit request to replace a running Worker's persisted Manifest or restart the live dogfooding process.
|
||||
@@ -216,6 +216,51 @@ test("unmount during schema setup closes the toolchain", async () => {
|
||||
expect(toolchain.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("builtin diagnostics show read-only paths without adding editable workspace files", async () => {
|
||||
toolchain.analyze.mockResolvedValue([{
|
||||
path: "$builtin/profiles/companion.dcdl",
|
||||
revision: 7,
|
||||
tree_digest: "sha256:tree",
|
||||
kind: "constraint_violation",
|
||||
span: { start_byte: 1, end_byte: 4 },
|
||||
message: "Imported value does not match the requested schema",
|
||||
labels: [],
|
||||
notes: [],
|
||||
}]);
|
||||
const view = renderEditor();
|
||||
await expectReady(view);
|
||||
await waitFor(() => {
|
||||
const diagnostic = view.container.querySelector(".config-source-diagnostics");
|
||||
expect(diagnostic?.textContent).toContain("$builtin/profiles/companion.dcdl (read-only builtin source)");
|
||||
expect(diagnostic?.textContent).toContain("bytes 1–4");
|
||||
});
|
||||
const paths = view.getByRole("navigation", { name: "Virtual configuration paths" });
|
||||
expect(paths.textContent).not.toContain("$builtin/");
|
||||
expect(paths.querySelectorAll("button")).toHaveLength(1);
|
||||
expect(view.container.querySelector('.cm-content[contenteditable="true"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("unknown builtin diagnostics retain the workspace importer path", async () => {
|
||||
toolchain.analyze.mockResolvedValue([{
|
||||
path: "main.dcdl",
|
||||
revision: 7,
|
||||
tree_digest: "sha256:tree",
|
||||
kind: "import",
|
||||
span: { start_byte: 8, end_byte: 40 },
|
||||
message: "unknown or non-public read-only builtin source: $builtin/profiles/missing.dcdl",
|
||||
labels: [],
|
||||
notes: [],
|
||||
}]);
|
||||
const view = renderEditor();
|
||||
await expectReady(view);
|
||||
await waitFor(() => {
|
||||
const diagnostic = view.container.querySelector(".config-source-diagnostics");
|
||||
expect(diagnostic?.textContent).toContain("main.dcdl · bytes 8–40");
|
||||
expect(diagnostic?.textContent).toContain("unknown or non-public read-only builtin source");
|
||||
});
|
||||
expect(view.getByRole("navigation", { name: "Virtual configuration paths" }).textContent).not.toContain("$builtin/");
|
||||
});
|
||||
|
||||
test("unmount during fetch does not start a toolchain", async () => {
|
||||
let finishFetch!: (response: Response) => void;
|
||||
fetcher.mockReturnValueOnce(
|
||||
|
||||
@@ -377,7 +377,7 @@
|
||||
<li>
|
||||
<strong>{diagnostic.kind}</strong>
|
||||
<span>{diagnostic.message}</span>
|
||||
<small>bytes {diagnostic.span.start_byte}–{diagnostic.span.end_byte}</small>
|
||||
<small>{diagnostic.path}{diagnostic.path.startsWith("$builtin/") ? " (read-only builtin source)" : ""} · bytes {diagnostic.span.start_byte}–{diagnostic.span.end_byte}</small>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
Binary file not shown.
@@ -226,6 +226,62 @@ Deno.test("config source API rejects unsafe revisions and oversized entry conten
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("config source API does not materialize imported builtins in the workspace file list", async () => {
|
||||
const body = {
|
||||
...tree,
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
entries: {
|
||||
"profiles/main.dcdl": {
|
||||
...entry,
|
||||
content: 'import "$builtin/profiles/companion.dcdl"',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const fetcher = (() => Promise.resolve(response(body))) as typeof fetch;
|
||||
const result = await fetchConfigTree("w", fetcher);
|
||||
assertEquals(Object.keys(result.snapshot.entries), ["profiles/main.dcdl"]);
|
||||
assertEquals(
|
||||
result.snapshot.entries["profiles/main.dcdl"].content,
|
||||
body.snapshot.entries["profiles/main.dcdl"].content,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("config source API preserves unknown read-only builtin diagnostics without fallback requests", async () => {
|
||||
const diagnostic = {
|
||||
path: "main.dcdl",
|
||||
revision: 7,
|
||||
tree_digest: "sha256:tree",
|
||||
kind: "import",
|
||||
span: { start_byte: 8, end_byte: 40 },
|
||||
message:
|
||||
"unknown or non-public read-only builtin source: $builtin/profiles/missing.dcdl",
|
||||
labels: [],
|
||||
notes: [],
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const fetcher = ((input: string | URL | Request) => {
|
||||
calls.push(String(input));
|
||||
return Promise.resolve(
|
||||
response({ error: JSON.stringify([diagnostic]) }, 400),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const error = await assertRejects(
|
||||
() =>
|
||||
commitConfigTree("w", {
|
||||
base_revision: 7,
|
||||
base_digest: "sha256:tree",
|
||||
changes: [],
|
||||
entrypoints: ["main.dcdl"],
|
||||
}, fetcher),
|
||||
ConfigSourceApiError,
|
||||
diagnostic.message,
|
||||
);
|
||||
assert(String(error).includes("main.dcdl"));
|
||||
assertEquals(calls, ["/api/w/w/config/source-tree/commit"]);
|
||||
});
|
||||
|
||||
Deno.test("config source API surfaces bounded failed evaluation details", async () => {
|
||||
const fetcher = (() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -89,6 +89,31 @@ Deno.test("completion starts only after non-whitespace typing", () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("toolchain preserves read-only builtin import completion detail", () => {
|
||||
const result = toCodeMirrorCompletion({
|
||||
from: 8,
|
||||
items: [{
|
||||
label: "$builtin/profiles/companion.dcdl",
|
||||
kind: "file",
|
||||
detail: "read-only builtin Decodal source",
|
||||
priority: 0,
|
||||
}],
|
||||
});
|
||||
assert(result !== null, "builtin completion should be available");
|
||||
assert(
|
||||
result.options[0].label === "$builtin/profiles/companion.dcdl",
|
||||
"builtin namespace must not become a workspace path",
|
||||
);
|
||||
assert(
|
||||
result.options[0].detail === "read-only builtin Decodal source",
|
||||
"CodeMirror must display the read-only completion detail",
|
||||
);
|
||||
assert(
|
||||
result.options[0].type === "file",
|
||||
"builtin imports remain file completions",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("toolchain preserves WASM UTF-16 completion ranges for CodeMirror", () => {
|
||||
const result = toCodeMirrorCompletion({
|
||||
from: "let 名 = ".length,
|
||||
|
||||
@@ -299,6 +299,150 @@ Deno.test("generated WASM returns completion items for the editor adapter", () =
|
||||
assertEquals(result.items[0].kind, "file");
|
||||
});
|
||||
|
||||
Deno.test("generated WASM exposes read-only builtin completions without editable builtin entries", () => {
|
||||
const tree = schemaSnapshot("{}");
|
||||
set_snapshot(tree);
|
||||
set_schema_bundle(emptySchemaBundle);
|
||||
const source = 'let 名 = 1; import "$builtin/profiles/comp"';
|
||||
const result = complete_current(
|
||||
"main.dcdl",
|
||||
source,
|
||||
source.length - 1,
|
||||
true,
|
||||
) as {
|
||||
from: number;
|
||||
items: Array<{ label: string; kind: string; detail: string }>;
|
||||
};
|
||||
const companion = result.items.find((item) =>
|
||||
item.label === "$builtin/profiles/companion.dcdl"
|
||||
);
|
||||
assertEquals(result.from, 'let 名 = 1; import "'.length);
|
||||
assertEquals(companion?.kind, "file");
|
||||
assertEquals(companion?.detail, "read-only builtin Decodal source");
|
||||
assertEquals(Object.keys(tree.entries), ["main.dcdl"]);
|
||||
const evaluated = evaluate_snapshot(
|
||||
schemaSnapshot('import "$builtin/profiles/companion.dcdl"'),
|
||||
mainEntrypointContract,
|
||||
) as {
|
||||
projections: Array<{ data_json: { slug: string } }>;
|
||||
};
|
||||
assertEquals(evaluated.projections[0].data_json.slug, "companion");
|
||||
});
|
||||
|
||||
Deno.test("generated WASM rejects unknown builtin imports without same-suffix workspace fallback", () => {
|
||||
const tree = schemaSnapshot('import "$builtin/profiles/missing.dcdl"');
|
||||
tree.entries["profiles/missing.dcdl"] = {
|
||||
path: "profiles/missing.dcdl",
|
||||
content_type: "decodal",
|
||||
content: "{}",
|
||||
content_digest: "sha256:workspace-fallback",
|
||||
};
|
||||
set_schema_bundle(emptySchemaBundle);
|
||||
const diagnostics = analyze_snapshot(tree, "main.dcdl", undefined) as Array<{
|
||||
path: string;
|
||||
revision: number;
|
||||
tree_digest: string;
|
||||
message: string;
|
||||
}>;
|
||||
assertEquals(diagnostics.length > 0, true);
|
||||
assertEquals(diagnostics[0].path, "main.dcdl");
|
||||
assertEquals(diagnostics[0].revision, tree.revision);
|
||||
assertEquals(diagnostics[0].tree_digest, tree.digest);
|
||||
assertEquals(
|
||||
diagnostics[0].message.includes(
|
||||
"unknown or non-public read-only builtin source",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
diagnostics[0].message.includes("$builtin/profiles/missing.dcdl"),
|
||||
true,
|
||||
);
|
||||
let failed = false;
|
||||
try {
|
||||
evaluate_snapshot(tree, mainEntrypointContract);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
assertEquals(
|
||||
(error as Array<{ message: string }>)[0].message.includes(
|
||||
"unknown or non-public read-only builtin source",
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
assertEquals(failed, true);
|
||||
assertEquals(
|
||||
analyze_snapshot(tree, "main.dcdl", 'import "./profiles/missing.dcdl"'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
function importFailure(tree: ConfigTreeSnapshot): ProjectedDiagnostic[] {
|
||||
try {
|
||||
evaluate_snapshot(tree, mainEntrypointContract);
|
||||
} catch (error) {
|
||||
// A WASM trap/stack overflow must not count as a structured import failure.
|
||||
assertEquals(Array.isArray(error), true);
|
||||
const diagnostics = error as Array<
|
||||
ProjectedDiagnostic & {
|
||||
revision: number;
|
||||
tree_digest: string;
|
||||
}
|
||||
>;
|
||||
assertEquals(diagnostics.length > 0, true);
|
||||
assertEquals(diagnostics[0].revision, tree.revision);
|
||||
assertEquals(diagnostics[0].tree_digest, tree.digest);
|
||||
return diagnostics;
|
||||
}
|
||||
throw new Error("expected a structured import diagnostic");
|
||||
}
|
||||
|
||||
Deno.test("generated WASM rejects nested lazy-object import cycles without a stack overflow", () => {
|
||||
const tree = schemaSnapshot('{ nested = import "./b.dcdl"; }');
|
||||
tree.entries["b.dcdl"] = {
|
||||
path: "b.dcdl",
|
||||
content_type: "decodal",
|
||||
content: '{ nested = import "./main.dcdl"; }',
|
||||
content_digest: "sha256:nested-cycle-b",
|
||||
};
|
||||
const diagnostics = importFailure(tree);
|
||||
assertEquals(diagnostics[0].kind, "cycle");
|
||||
assertEquals(diagnostics[0].message.includes("import cycle"), true);
|
||||
});
|
||||
|
||||
Deno.test("generated WASM enforces the import depth budget on a real 33-deep chain", () => {
|
||||
function chain(depth: number): ConfigTreeSnapshot {
|
||||
const tree = schemaSnapshot('import "./chain/1.dcdl"');
|
||||
for (let index = 1; index <= depth; index++) {
|
||||
const path = `chain/${index}.dcdl`;
|
||||
tree.entries[path] = {
|
||||
path,
|
||||
content_type: "decodal",
|
||||
content: index === depth
|
||||
? "{ answer = 42; }"
|
||||
: `import "./${index + 1}.dcdl"`,
|
||||
content_digest: `sha256:chain-${depth}-${index}`,
|
||||
};
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
// The synthetic evaluation-wrapper import consumes one of the 32 edges.
|
||||
const accepted = evaluate_snapshot(chain(31), mainEntrypointContract) as {
|
||||
projections: Array<{ data_json: { answer: number } }>;
|
||||
};
|
||||
assertEquals(accepted.projections[0].data_json, { answer: 42 });
|
||||
for (const depth of [32, 33]) {
|
||||
const diagnostics = importFailure(chain(depth));
|
||||
assertEquals(diagnostics[0].kind, "import");
|
||||
assertEquals(
|
||||
diagnostics.some((diagnostic) =>
|
||||
diagnostic.message.includes("import depth")
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("generated WASM completes blank nested schema positions after Unicode", () => {
|
||||
const source =
|
||||
'{ description = "日本語"; profile = { }\n} as WorkspaceConfigSchema';
|
||||
|
||||
Reference in New Issue
Block a user