Repository navigation
[Toolchain][Workspace][P1] Replace member-loop builds and synthetic workspace locks with a canonical graph #15
Description
Summary
CellScript has a workspace surface, but its build and lock semantics are not yet a coherent workspace dependency graph.
The current implementation:
- resolves member paths from workspace.members, but ignores workspace.exclude;
- builds members in declaration order with an independent package compile for each member;
- does not construct a workspace-wide dependency DAG or build-unit graph;
- does not topologically schedule member dependencies;
- does not refresh each successful member's package_build identity;
- writes successful member artifact hashes into the workspace Cell.lock as dependency nodes with an empty version, a placeholder manifest digest, no root edges, and no dependency edges.
The last point is especially misleading: the resulting file passes the structural lock schema, but it is neither a source-resolution lock nor an artifact bundle.
Code evidence
- WorkspaceConfig declares both members and exclude:
Lines 79 to 85 in 8ae6dc4
#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct WorkspaceConfig { #[serde(default)] pub members: Vec<String>, #[serde(default)] pub exclude: Vec<String>, } - Member resolution reads only members and treats each string as one literal directory: https://github.com/CellScript-Labs/CellScript/blob/8ae6dc4a2749fa107ff42c166772600648c19bb7/src/lib.rs#L7284-L7317
- Workspace build is a serial loop over members:
CellScript/src/cli/commands.rs
Lines 1096 to 1203 in 8ae6dc4
fn build_workspace(args: BuildArgs) -> Result<()> { let ws_root = crate::find_workspace_root(Utf8Path::new("."))?.ok_or_else(|| { crate::error::CompileError::without_span( "no workspace root found; run from a directory containing a [workspace] Cell.toml", ) })?; let all_members = crate::resolve_workspace_members(&ws_root)?; let members: Vec<_> = if let Some(ref pkg_name) = args.package { // Find the specific member by reading its manifest for the package name. let mut found = Vec::new(); for member_dir in &all_members { let pm = crate::package::PackageManager::new(member_dir.as_std_path()); let manifest = pm.read_manifest()?; if manifest.package.name == *pkg_name { found.push(member_dir.clone()); } } if found.is_empty() { return Err(crate::error::CompileError::without_span(format!( "workspace member '{}' not found; available members: {}", pkg_name, all_members.iter().map(|m| m.as_str().to_string()).collect::<Vec<_>>().join(", ") ))); } found } else { all_members }; let opt_level = if args.release { 3 } else { 1 }; let mut member_results = Vec::new(); let mut human_lines = Vec::new(); let mut failure_diagnostics = Vec::new(); let mut failed = 0; let policy_args = effective_build_check_args(&args)?; let executable_surface_policy = executable_surface_policy(&policy_args); for member_dir in &members { let options = CompileOptions { edition: crate::CURRENT_EDITION, opt_level, output: None, debug: false, target: args.target.clone(), target_profile: args.target_profile.clone(), primitive_compat: args.primitive_compat.clone(), }; let resolution_options = build_resolution_options(&args, crate::package::DependencyScope::Runtime); let entry_scope = match (args.entry_action.as_deref(), args.entry_lock.as_deref()) { (Some(action), None) => Some(CompileEntryScope::Action(action.to_string())), (None, Some(lock)) => Some(CompileEntryScope::Lock(lock.to_string())), (None, None) => None, (Some(_), Some(_)) => { return Err(crate::error::CompileError::without_span("--entry-action and --entry-lock are mutually exclusive")); } }; let compile_result = crate::package::with_resolution_options(resolution_options, || { compile_path_with_executable_surface_policy(member_dir, options, entry_scope, executable_surface_policy) }); match compile_result { Ok(result) => { if let Err(e) = validate_check_policy(&result.metadata, &policy_args) { failure_diagnostics.push(e.clone().with_file(member_dir.clone())); member_results.push(serde_json::json!({ "member": member_dir.as_str(), "status": "failed", "error": e.message, })); failed += 1; continue; } let resolved = resolve_input_path(member_dir)?; let output_path = default_output_path_for_input(member_dir, &resolved, result.artifact_format)?; result.write_to_path(&output_path)?; let metadata_path = default_metadata_path_for_artifact(&output_path); result.write_metadata_to_path(&metadata_path)?; let verified_sidecars = result.write_verified_artifact_sidecars(&output_path)?; member_results.push(serde_json::json!({ "member": member_dir.as_str(), "status": "ok", "artifact": output_path.to_string(), "metadata": metadata_path.to_string(), "lowering_record": verified_sidecars.as_ref().map(|paths| paths.0.to_string()), "source_map": verified_sidecars.as_ref().map(|paths| paths.1.to_string()), "artifact_format": result.artifact_format.display_name(), "target_profile": result.metadata.target_profile.name, "artifact_hash": result.metadata.artifact_hash, "artifact_size_bytes": result.artifact_bytes.len(), "cache_hit": result.cache_hit, })); human_lines.push(format!("{} {}", "Built".green(), member_dir)); } Err(e) => { failure_diagnostics.push(e.clone().with_file(member_dir.clone())); member_results.push(serde_json::json!({ "member": member_dir.as_str(), "status": "failed", "error": e.message, })); failed += 1; } } } - The workspace lock writer inserts synthetic dependency records with version = "", manifest_digest = "workspace-member-artifact", empty edges, and the artifact hash in source_hash:
CellScript/src/cli/commands.rs
Lines 1215 to 1239 in 8ae6dc4
// Write workspace-level Cell.lock at the workspace root. let mut lockfile = Lockfile::read_from_root(ws_root.as_std_path())?.unwrap_or_else(Lockfile::new); // Merge build hashes from each successfully built member. for res in &member_results { if res["status"].as_str() == Some("ok") { let member_name = res["member"].as_str().unwrap_or("unknown"); let artifact_hash = res.get("artifact_hash").and_then(|v| v.as_str()).unwrap_or(""); if !artifact_hash.is_empty() { lockfile.dependencies.insert( member_name.to_string(), crate::package::LockedDependency { name: member_name.to_string(), namespace: None, version: String::new(), source: crate::package::LockedSource::Path { path: member_name.to_string() }, source_hash: Some(artifact_hash.to_string()), manifest_digest: "workspace-member-artifact".to_string(), dependencies: BTreeMap::new(), build: None, }, ); } } } lockfile.write_to_root(ws_root.as_std_path())?;
The existing tests prove that two independent members compile and that a member with its own pre-created lock can import a path dependency. They do not prove a shared workspace resolve, topological scheduling, one authoritative lock, duplicate-name rejection, exclude handling, or downstream invalidation.
Why this matters
A workspace lock is a trust boundary. Tooling must not use the same Cell.lock schema for two different meanings:
- exact source dependency resolution; and
- an ad hoc list of built member artifact hashes.
A consumer cannot currently answer:
- which workspace member depends on which other member;
- whether a selected member was built after its dependencies;
- which environment and feature roots were used per member;
- whether the workspace lock is authoritative for member builds;
- which member should be rebuilt after a source, interface, target-profile, or deployment-identity change.
Reference architecture
Cargo threads one Workspace through resolution and compilation, shares target/build directories, resolves all selected roots, then constructs an immutable UnitGraph before scheduling work:
- workspace model: https://github.com/rust-lang/cargo/blob/75d17360928f57ff2a7d2f2da1c753f5fe1926d1/src/workspace/workspace.rs#L45-L98
- resolve-to-unit pipeline: https://github.com/rust-lang/cargo/blob/75d17360928f57ff2a7d2f2da1c753f5fe1926d1/src/ops/cargo_compile/mod.rs#L1-L37
- serialized unit graph: https://github.com/rust-lang/cargo/blob/75d17360928f57ff2a7d2f2da1c753f5fe1926d1/src/compiler/unit_graph.rs#L13-L150
Move package-alt similarly models a rooted package DAG and separates graph loading from BuildPlan compilation:
- rooted DAG: https://github.com/MystenLabs/sui/blob/5a9f37431c473fa2f6d49abecbcc6a6d7190f533/external-crates/move/crates/move-package-alt/src/graph/mod.rs#L40-L75
- build plan: https://github.com/MystenLabs/sui/blob/5a9f37431c473fa2f6d49abecbcc6a6d7190f533/external-crates/move/crates/move-package-alt-compilation/src/build_plan.rs
CellScript should adopt the separation, not Cargo's exact target model.
Proposed contract
Introduce a versioned workspace resolve/build plan with:
- canonical member identity: package name, version, manifest path, and manifest digest;
- duplicate package-name and duplicate canonical-path rejection;
- explicit members, exclude, and optionally default-members semantics;
- one workspace source graph with root/member edges and exact node identities;
- an explicit rule for whether member-local Cell.lock files are forbidden, subordinate, or independently authoritative;
- topological member scheduling with cycle diagnostics;
- build units keyed by member, entry, target, target profile, compatibility profile, feature root, environment identity, and dependency scope;
- separate source-resolution identity and build-artifact identity;
- graph-derived incremental invalidation;
- deterministic JSON output and failure aggregation;
- atomic lock writes only after the complete selected graph validates.
Do not store artifact records as ordinary LockedDependency nodes. If the workspace needs artifact closure, define a separate versioned workspace evidence manifest or consume the ProtocolBundle design in #9.
Acceptance criteria
- workspace.exclude is either implemented and tested or removed from the public schema.
- Member names and canonical paths are unique.
- Member dependency edges are resolved once for the workspace and cycles fail before compilation.
- Members are built in graph order; independent units may run in parallel without changing output identity.
- A selected package build includes the transitive member closure it needs.
- The authoritative lock model is documented and cannot be confused with an artifact list.
- Workspace builds do not write placeholder manifest digests, empty package versions, or unreachable dependency nodes.
- Member package_build identities and verified artifact sidecars are refreshed consistently.
- Environment, feature, target/profile, test/runtime, locked/frozen/offline options are represented in the plan.
- JSON output exposes the resolved roots, units, edges, cache hits, and failures.
- Negative tests cover duplicate names, cycles, reversed declaration order, stale member locks, excluded members, and dependency failure propagation.
- dev and ci gates include a workspace with at least a three-member diamond dependency graph.
Non-goals
- turning all repository fixtures into workspace members;
- introducing an ELF linker;
- weakening lock-authoritative or frozen/offline behavior;
- treating workspace membership as a deployment or protocol-composition claim.
Activity
a19q3 commentedon Sep 11, 2026
Completed on 0.30 in
79c43ae5: workspace builds use one canonical resolved graph and build-unit ordering instead of independent member loops or synthetic lock nodes. Subsequent build-plan ordering regression coverage landed in4d9e6439. The product capability ledger records the admitted scope as complete withremaining_work=[]atcdc5b53c. No TAC or release workflow was invoked.