Skip to content

[Toolchain][Resolver][P1] Define and enforce package instance, version, source, and feature unification #16

Description

@a19q3

Summary

CellScript's documented dependency policy and implemented resolver currently disagree.

The package provenance documentation says that one version of each package exists in a graph and that conflicting requirements fail closed. The implementation instead keys nodes by package name, version, source, environment label, and feature set. It can therefore retain multiple instances of the same declared package without a graph-wide unification decision.

The compiler later flattens all resolved source roots into one module resolver. Duplicate module identities then fail only at source loading, far downstream from dependency resolution.

Code and documentation evidence

  • The documented single-version policy and conflict diagnostic:
    #### 10. Dependency Version Conflict Resolution
    **Gap**: No defined strategy when two dependencies require different
    versions of the same package.
    **Resolution**: The resolver uses a conservative single-version strategy:
    - A single version of each package exists in the dependency graph.
    - If `amm` requires `token ^0.3.0` and `vesting` requires `token ^0.3.1`,
    the resolver picks `token 0.3.2` when that version is the latest version
    satisfying the first resolved constraint and also satisfies the later
    constraint.
    - If a transitive request names a version requirement that the already-selected
    version does not satisfy, resolution **fails closed** with a
    `version conflict for '<pkg>': already resolved to '<v>', which does not
    satisfy requirement '<req>'` error, instead of silently keeping whichever
    version was resolved first.
    - The current implementation does not backtrack or re-solve the whole graph
    when a later constraint would require a different still-compatible version;
    that remains future resolver work.
    - Future work may support multiple versions (like Go's `MVS` + `replace`),
    but the current resolver keeps it simple: one version per package per graph,
    with fail-closed conflict detection.
  • package_node_id deliberately includes version, source, environment, and features:
    fn package_node_id(package: &ResolvedPackage, options: &ResolutionOptions) -> String {
    let source = match &package.source {
    PackageSource::Local(path) => format!("path:{}", path.to_string_lossy().replace('\\', "/")),
    PackageSource::Git { url, revision } => format!("git:{url}#{revision}"),
    PackageSource::Registry { registry, namespace, version, revision, .. } => {
    format!("registry:{registry}:{namespace}/{}@{version}#{revision}", package.name)
    }
    };
    let mut features: Vec<_> = options.features.iter().cloned().collect();
    if options.all_features {
    features.push("*".to_string());
    }
    if !options.no_default_features {
    features.push("default".to_string());
    }
    features.sort();
    let environment = options.environment.as_deref().unwrap_or("default");
    format!("{}@{}|{}|env={}|features={}", package.name, package.version, source, environment, features.join(","))
  • resolve_dependency_from_root reuses only an identical node_id; it has no canonical package-coordinate conflict table or backtracking solver:

    CellScript/src/package/mod.rs

    Lines 1180 to 1290 in 8ae6dc4

    }
    }
    fn resolve_dependency_from_root(
    &mut self,
    alias: &str,
    dep: &Dependency,
    base_root: &Path,
    parent_options: &ResolutionOptions,
    stack_ids: &mut Vec<String>,
    stack_labels: &mut Vec<String>,
    ) -> Result<String> {
    let package_name = dependency_package_name(alias, dep);
    let (mut resolved, manifest) = match dep {
    Dependency::Simple(version) => {
    self.resolve_from_registry_with_manifest(&package_name, version, None, registry::RegistryResolutionPolicy::default())?
    }
    Dependency::Detailed(detailed) => {
    if detailed.resolver.is_some() {
    let normalized = self.resolve_external_dependency(alias, &package_name, detailed, base_root, parent_options)?;
    let resolved = if let Some(git) = &normalized.git {
    self.resolve_from_git_with_manifest(&package_name, git, &normalized)?
    } else {
    self.resolve_from_registry_with_manifest(
    &package_name,
    &normalized.version,
    normalized.namespace.as_deref(),
    registry::RegistryResolutionPolicy::default(),
    )?
    };
    let exact = version::parse_version_req(&normalized.version)?;
    if !version::satisfies(&resolved.0.version, &exact) {
    return Err(CompileError::without_span(format!(
    "external resolver for '{}' declared version inconsistent with materialized package '{}'",
    alias, resolved.0.version
    )));
    }
    resolved
    } else if let Some(path) = &detailed.path {
    self.resolve_from_path_at(&package_name, path, base_root)?
    } else if let Some(git) = &detailed.git {
    self.resolve_from_git_with_manifest(&package_name, git, detailed)?
    } else {
    let ns = detailed.namespace.as_deref();
    self.resolve_from_registry_with_manifest(
    &package_name,
    &detailed.version,
    ns,
    registry::RegistryResolutionPolicy {
    allow_unverified: detailed.allow_unverified,
    allow_quarantined: detailed.allow_quarantined,
    },
    )?
    }
    }
    };
    self.validate_manifest_package_contract(&manifest)?;
    if manifest.package.name != package_name {
    return Err(CompileError::without_span(format!(
    "dependency alias '{}' expects package '{}' but '{}' declares package name '{}'",
    alias,
    package_name,
    resolved.path.display(),
    manifest.package.name
    )));
    }
    let child_options = dependency_resolution_options(dep, parent_options);
    let node_id = package_node_id(&resolved, &child_options);
    if let Some(requirement) = self.version_requirement_of(dep) {
    let requirement = version::parse_version_req(&requirement)?;
    if !version::satisfies(&resolved.version, &requirement) {
    return Err(CompileError::without_span(format!(
    "dependency alias '{}' resolved package '{}' to '{}', which does not satisfy its requirement",
    alias, package_name, resolved.version
    )));
    }
    }
    if let Some(existing) = self.resolved.get(&node_id) {
    if existing.manifest_digest != resolved.manifest_digest || existing.source_hash != resolved.source_hash {
    return Err(CompileError::without_span(format!(
    "dependency node '{}' resolved with conflicting manifest or source identity",
    node_id
    )));
    }
    return Ok(node_id);
    }
    if let Some(position) = stack_ids.iter().position(|item| item == &node_id) {
    let mut cycle = stack_labels[position..].to_vec();
    cycle.push(alias.to_string());
    return Err(CompileError::without_span(format!("Circular dependency detected: {}", cycle.join(" -> "))));
    }
    stack_ids.push(node_id.clone());
    stack_labels.push(alias.to_string());
    let child_dependencies = self.selected_dependencies(&manifest, &child_options, false)?;
    let mut child_edges = BTreeMap::new();
    for (child_alias, child_dep) in child_dependencies {
    let child_id =
    self.resolve_dependency_from_root(&child_alias, &child_dep, &resolved.path, &child_options, stack_ids, stack_labels)?;
    child_edges.insert(child_alias, child_id);
    }
    stack_ids.pop();
    stack_labels.pop();
    resolved.node_id = node_id.clone();
    resolved.dependencies = child_edges;
    self.resolved.insert(node_id.clone(), resolved);
    Ok(node_id)
    }
  • all resolved package paths are added as source roots:

    CellScript/src/package/mod.rs

    Lines 1997 to 2000 in 8ae6dc4

    pub fn get_source_paths(&self) -> Vec<PathBuf> {
    self.resolved.values().map(|p| p.path.join("src")).collect()
    }
    }
  • the module resolver rejects duplicate module names only after parsing/loading:
    }
    pub fn register_module(&mut self, module: Module) -> Result<()> {
    self.register_module_in_package(module, "__cellscript_single_package__")
    }
    pub fn register_module_in_package(&mut self, module: Module, package_id: impl Into<String>) -> Result<()> {
    let name = module.name.clone();
    if self.modules.contains_key(&name) {
    return Err(CompileError::new(format!("duplicate module '{}'", name), module.span));
    }

This means a graph can be structurally lock-valid while its package-instance semantics remain undefined.

Why this is a correctness boundary

The resolver must decide, before source loading:

  • whether two requirements denote the same logical package;
  • whether multiple versions may coexist;
  • whether the same package may come from multiple sources;
  • whether features unify, remain edge-local, or create distinct package instances;
  • how aliases affect source-level module identity;
  • whether a dependency override is an authorized graph-wide substitution or only one edge's source choice.

Without that decision, failures depend on incidental module names. Two versions with disjoint module names may compile, while two versions with the expected same module name fail later. Neither behavior matches the documented single-version policy.

Reference models

Cargo permits multiple semver-incompatible versions but enforces one compatible version per package/source activation domain and performs graph-wide backtracking:

https://github.com/rust-lang/cargo/blob/75d17360928f57ff2a7d2f2da1c753f5fe1926d1/src/resolver/mod.rs#L1-L37

Move package-alt explicitly allows multiple versions in a PackageID-keyed rooted DAG and carries dependency names/renames as graph edges:

https://github.com/MystenLabs/sui/blob/5a9f37431c473fa2f6d49abecbcc6a6d7190f533/external-crates/move/crates/move-package-alt/src/graph/mod.rs#L40-L53

These are different valid models. CellScript currently implements parts of both without the namespace and linkage rules needed by either.

Recommended first contract

For the next stable package schema, prefer a conservative fail-closed model:

  1. Define a canonical package coordinate, including namespace and declared package name.
  2. Allow at most one resolved version/source for that coordinate in one selected runtime or test graph.
  3. Collect all version requirements before materialization and solve one compatible version, or emit a diagnostic containing every requiring edge.
  4. Treat a source change as a different candidate only through an explicit root-authorized override.
  5. Define feature unification explicitly. If feature variants are separate lock nodes, prove that flattening their sources and child closures is equivalent to the intended union; otherwise store one unified feature set per package instance.
  6. Reject package-instance conflicts before reading CellScript modules.

A later multi-version design is possible only after source-level module identity becomes package-instance-qualified and public interfaces, monomorphization, metadata, source maps, and Registry coordinates preserve that qualification.

Required diagnostics

A conflict report should include:

  • canonical package coordinate;
  • every incoming edge and alias;
  • requested version range;
  • selected environment and feature roots;
  • candidate source;
  • current locked choice;
  • whether the conflict is version, source, namespace, feature, or environment related;
  • a suggested explicit update/override action when one is safe.

Acceptance criteria

  • One normative package-instance and unification model is accepted.
  • Documentation and resolver behavior agree.
  • Conflicts fail during resolution, not as incidental duplicate-module errors.
  • The lock schema records the chosen model unambiguously.
  • Same-name/different-source substitution requires explicit policy.
  • Feature activation semantics are tested across a dependency diamond.
  • Tests cover compatible overlapping ranges, incompatible ranges, aliases, namespaces, path-vs-Registry conflicts, Git-vs-Registry conflicts, and two transitive parents.
  • Locked/frozen mode reproduces exactly the selected instances without re-solving.
  • A future multi-version mode cannot silently reuse the current unqualified module namespace.

Non-goals

  • adopting Cargo's resolver wholesale;
  • allowing arbitrary duplicate module identities;
  • making package aliases equivalent to package identity;
  • using Registry availability as part of locked compilation.

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