Skip to content

[Toolchain][Workspace][P1] Replace member-loop builds and synthetic workspace locks with a canonical graph #15

Description

@a19q3

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:
    #[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:

  1. exact source dependency resolution; and
  2. 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:

Move package-alt similarly models a rooted package DAG and separates graph loading from BuildPlan compilation:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions