Skip to content

[Toolchain][Compatibility][P1] Enforce package-declared CellScript compiler requirements #18

Description

@a19q3

Summary

PackageInfo already contains cellscript_version, but package validation and dependency resolution do not enforce it.

The field is currently inert for source packages. A dependency can therefore declare a compiler/toolchain requirement that the active cellc does not satisfy, and resolution proceeds until parsing, lowering, metadata validation, or artifact generation fails for some unrelated reason.

Edition is not a substitute for a minimum compiler version:

  • edition selects language semantics;
  • compiler requirement selects the minimum toolchain that understands the manifest, package schema, language surface, target profile, and metadata/checker contracts;
  • reproducible artifact identity records the exact compiler and build inputs.

These must remain separate.

Code evidence

  • PackageInfo declares cellscript_version:
    #[derive(Debug, Clone, Serialize, Deserialize)]
    pub struct PackageInfo {
    pub name: String,
    pub version: String,
    pub edition: CellScriptEdition,
    #[serde(default)]
    pub namespace: Option<String>,
    #[serde(default)]
    pub authors: Vec<String>,
    #[serde(default)]
    pub description: String,
    #[serde(default)]
    pub license: String,
    #[serde(default)]
    pub repository: String,
    #[serde(default)]
    pub homepage: String,
    #[serde(default)]
    pub documentation: String,
    #[serde(default)]
    pub keywords: Vec<String>,
    #[serde(default)]
    pub categories: Vec<String>,
    #[serde(default)]
    pub cellscript_version: String,
    #[serde(default = "default_entry")]
    pub entry: String,
    #[serde(default)]
    pub source_roots: Vec<String>,
    #[serde(default)]
    pub include: Vec<String>,
    #[serde(default)]
    pub exclude: Vec<String>,
  • validate_manifest_package_contract checks package SemVer, environments, resolver declarations, and reserved build dependencies, but never reads cellscript_version:

    CellScript/src/package/mod.rs

    Lines 1052 to 1111 in 8ae6dc4

    fn validate_manifest_package_contract(&self, manifest: &PackageManifest) -> Result<()> {
    semver::Version::parse(&manifest.package.version).map_err(|error| {
    CompileError::without_span(format!(
    "package '{}' has invalid semantic version '{}': {error}",
    manifest.package.name, manifest.package.version
    ))
    })?;
    for (name, environment) in &manifest.environments {
    validate_environment(name, environment)?;
    }
    for environment in manifest.dependency_overrides.keys() {
    if !manifest.environments.contains_key(environment) {
    return Err(CompileError::without_span(format!(
    "dependency override environment '{}' has no matching [environments.{}] declaration",
    environment, environment
    )));
    }
    }
    for (name, resolver) in &manifest.resolvers {
    if name.trim().is_empty() {
    return Err(CompileError::without_span("resolver names must not be empty"));
    }
    let command = Path::new(&resolver.command);
    if !command.is_absolute() {
    return Err(CompileError::without_span(format!("resolver '{}' command must be an absolute executable path", name)));
    }
    let digest = resolver.sha256.strip_prefix("sha256:").unwrap_or(&resolver.sha256);
    if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
    return Err(CompileError::without_span(format!("resolver '{}' sha256 must contain exactly 32 bytes of hex", name)));
    }
    }
    let declared_dependencies = manifest
    .dependencies
    .iter()
    .chain(manifest.dev_dependencies.iter())
    .chain(manifest.dependency_overrides.values().flat_map(|dependencies| dependencies.iter()));
    for (alias, dependency) in declared_dependencies {
    if let Dependency::Detailed(detail) = dependency
    && let Some(resolver) = detail.resolver.as_deref()
    {
    if detail.path.is_some() || detail.git.is_some() {
    return Err(CompileError::without_span(format!(
    "dependency '{}' cannot combine resolver with path or git",
    alias
    )));
    }
    if !manifest.resolvers.contains_key(resolver) {
    return Err(CompileError::without_span(format!(
    "dependency '{}' selects undeclared resolver '{}'",
    alias, resolver
    )));
    }
    }
    }
    if !manifest.build.dependencies.is_empty() {
    return Err(CompileError::without_span(
    "[build.dependencies] is reserved until isolated build-script execution is implemented; use [dependencies] for compile-time imports",
    ));
    }
    Ok(())
  • dependency lock nodes have an optional build.compiler_version, but normal resolution writes build: None:

    CellScript/src/package/mod.rs

    Lines 2282 to 2304 in 8ae6dc4

    pub fn update_from_resolved(&mut self, resolved: &BTreeMap<String, ResolvedPackage>) {
    for (node_id, package) in resolved {
    let locked = LockedDependency {
    name: package.name.clone(),
    namespace: package.namespace.clone(),
    version: package.version.clone(),
    source: match &package.source {
    PackageSource::Local(path) => LockedSource::Path { path: path.to_string_lossy().to_string() },
    PackageSource::Git { url, revision } => LockedSource::Git { url: url.clone(), revision: revision.clone() },
    PackageSource::Registry { registry, url, revision, namespace, version } => LockedSource::Registry {
    registry: registry.clone(),
    url: url.clone(),
    revision: revision.clone(),
    namespace: namespace.clone(),
    version: version.clone(),
    },
    },
    source_hash: package.source_hash.clone(),
    manifest_digest: package.manifest_digest.clone(),
    dependencies: package.dependencies.clone(),
    build: None,
    };
    self.dependencies.insert(node_id.clone(), locked);
  • Registry records carry a cellscript_version string, but that does not make local or locked source compilation enforce it.

Cargo comparison

Cargo treats package.rust-version as an active compatibility constraint. Before compilation it checks every unit against the current rustc and reports all incompatible packages with upgrade or precise-version guidance:

https://github.com/rust-lang/cargo/blob/75d17360928f57ff2a7d2f2da1c753f5fe1926d1/src/ops/cargo_compile/mod.rs#L642-L690

Cargo's update reporting also distinguishes newer versions that remain compatible with the required Rust version:

https://github.com/rust-lang/cargo/blob/75d17360928f57ff2a7d2f2da1c753f5fe1926d1/src/ops/cargo_update.rs#L540-L648

CellScript needs the same principle, adapted to its stronger edition/profile/checker identity boundaries.

Proposed semantics

Define a normative manifest key and remove ambiguity around the existing field. For example, it may mean a SemVer requirement over cellc releases, not an exact artifact identity.

Required behavior:

  • parse the requirement with the same standard SemVer implementation used for package versions;
  • validate the root package before loading source;
  • validate every dependency during candidate selection and again during locked materialization;
  • reject candidates whose compiler requirement is incompatible with the active cellc;
  • include the requirement in manifest digests and package/Registry metadata;
  • preserve the selected package requirement and actual compiler version in machine-readable build evidence;
  • make cellc update capable of selecting the newest package version compatible with the active compiler;
  • provide an explicit, auditable override only if a concrete migration workflow requires it; do not silently ignore the field.

An exact compiler build/source hash still belongs in reproducible build evidence. Do not force exact-compiler pinning merely to express source compatibility.

Compatibility dimensions

The check should report separately:

  • compiler release range;
  • CellScript edition;
  • manifest/lock schema version;
  • target and target-profile support;
  • metadata and independent-checker schema compatibility;
  • optional feature requirements.

This avoids the misleading conclusion that matching one version string proves artifact reproducibility.

Acceptance criteria

  • The public manifest spelling and SemVer meaning are documented.
  • Empty, malformed, and impossible requirements fail with stable diagnostics.
  • Root and transitive packages are checked before source loading.
  • Registry selection filters incompatible package versions deterministically.
  • Locked/frozen builds revalidate the requirement without re-resolving.
  • Machine JSON names every incompatible package and incoming edge.
  • update can recommend/select a compatible version without silently crossing interface policy.
  • Tests cover root, path, Git, Registry, transitive, pre-release, and old-lock cases.
  • Edition and exact reproducible compiler identity remain separate fields.

Non-goals

  • claiming semantic equivalence between compiler releases;
  • weakening exact compiler identity in release/reproducibility evidence;
  • treating a Registry record as proof that an artifact was built by the declared compiler.

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