Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Implemented project assembly ([#2877](https://github.com/0xMiden/miden-vm/pull/2877)).
- Added `FastProcessor::into_parts()` to extract advice provider, memory, and precompile transcript after step-based execution ([#2901](https://github.com/0xMiden/miden-vm/pull/2901)).
- Added warning diagnostic for unused private constants in modules ([#2993](https://github.com/0xMiden/miden-vm/pull/2993)).

## 0.22.0 (2026-03-18)

Expand Down
96 changes: 95 additions & 1 deletion crates/assembly-syntax/src/sema/context.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use alloc::{
boxed::Box,
collections::{BTreeMap, BTreeSet},
collections::{BTreeMap, BTreeSet, VecDeque},
sync::Arc,
vec::Vec,
};
Expand All @@ -17,6 +17,16 @@ use crate::ast::{
/// This maintains the state for semantic analysis of a single [Module].
pub struct AnalysisContext {
constants: BTreeMap<Ident, Constant>,
used_constants: BTreeSet<Ident>,
/// Edges from a constant to the constants it references.
/// Used to compute transitive usage from real roots.
constant_deps: BTreeMap<Ident, BTreeSet<Ident>>,
/// Edges from a constant to the imports it references.
/// Used to avoid marking imports as used when only dead constants reference them.
constant_import_refs: BTreeMap<Ident, BTreeSet<alloc::string::String>>,
/// When set, references are recorded as constant-to-constant edges
/// rather than marking the target as directly used.
simplifying_constant: Option<Ident>,
imported: BTreeSet<Ident>,
procedures: BTreeSet<ProcedureName>,
errors: Vec<SemanticAnalysisError>,
Expand All @@ -38,6 +48,14 @@ impl constants::ConstEnvironment for AnalysisContext {
#[inline]
fn get(&mut self, name: &Ident) -> Result<Option<CachedConstantValue<'_>>, Self::Error> {
if let Some(constant) = self.constants.get(name) {
let is_self_ref = self.simplifying_constant.as_ref() == Some(name);
if !is_self_ref {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because get() runs during simplify_constants(), this marks A as used even in a dead chain like const A = 1 and const B = A where neither constant is referenced from any procedure or exported API. In that case only B warns and A is missed.

To really address this, we need to track constant-to-constant edges here and compute reachability from real roots.

Copy link
Copy Markdown
Contributor Author

@giwaov giwaov Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - fixed in 5cc929c. Instead of marking constants as used during \get(), I now track constant-to-constant edges in a separate map and then do a BFS from real roots (procedure references + public constants) to figure out which ones are actually reachable. So \const A = 1; const B = A\ correctly warns on both if neither is referenced.

Added tests for both cases - both unused should warn, and transitive usage through a chain shouldn't.

if let Some(ref parent) = self.simplifying_constant {
self.constant_deps.entry(parent.clone()).or_default().insert(name.clone());
} else {
self.used_constants.insert(name.clone());
}
}
Ok(Some(CachedConstantValue::Miss(&constant.value)))
} else if self.imported.contains(name) {
// We don't have the definition available yet
Expand Down Expand Up @@ -67,6 +85,10 @@ impl AnalysisContext {
pub fn new(source_file: Arc<SourceFile>, source_manager: Arc<dyn SourceManager>) -> Self {
Self {
constants: Default::default(),
used_constants: Default::default(),
constant_deps: Default::default(),
constant_import_refs: Default::default(),
simplifying_constant: None,
imported: Default::default(),
procedures: Default::default(),
errors: Default::default(),
Expand Down Expand Up @@ -98,6 +120,76 @@ impl AnalysisContext {
self.imported.insert(name);
}

/// Returns true if the constant has been referenced by another constant or
/// by a procedure body, or is publicly visible.
pub fn is_constant_used(&self, constant: &Constant) -> bool {
constant.visibility.is_public() || self.used_constants.contains(&constant.name)
}

/// Mark a constant as used.
///
/// This is used for constants created as a side effect of other declarations
/// (e.g. advice map entries) that should not trigger unused constant warnings.
pub fn mark_constant_used(&mut self, name: &Ident) {
self.used_constants.insert(name.clone());
}

/// Record that constant `constant_name` references import `import_name`.
///
/// These edges are used to defer import-usage bookkeeping until after
/// constant liveness has been resolved, so that imports reached only from
/// dead constants are correctly reported as unused.
pub fn record_constant_import_ref(
&mut self,
constant_name: &Ident,
import_name: alloc::string::String,
) {
self.constant_import_refs
.entry(constant_name.clone())
.or_default()
.insert(import_name);
}

/// Increment `alias.uses` only for imports that are referenced by live
/// constants. Must be called after `resolve_constant_usage`.
pub fn apply_live_constant_import_refs(&self, module: &mut Module) {
for (constant_name, import_names) in &self.constant_import_refs {
if !self.used_constants.contains(constant_name) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The liveness check here skips pub const roots because resolve_constant_usage() only pushes public constants onto the worklist, it does not insert their own names into used_constants.

That means pub const LIVE = a::BAR still leaves use lib::a looking unused even though the import is needed to evaluate an exported constant.

Marking public constants as live before applying deferred import refs, and adding a regression test for pub const LIVE = a::BAR, should close the hole.

continue;
}
for import_name in import_names {
if let Some(alias) =
module.aliases_mut().find(|a| a.name().as_str() == import_name.as_str())
{
alias.uses += 1;
}
}
}
}

/// Propagate usage from real roots through constant-to-constant edges.
///
/// A constant is considered truly used if it is public, directly referenced
/// by a procedure body, or transitively referenced by such a constant.
/// This must be called before checking for unused constants.
pub fn resolve_constant_usage(&mut self) {
let mut worklist: VecDeque<Ident> = self.used_constants.iter().cloned().collect();
for (name, constant) in &self.constants {
if constant.visibility.is_public() && self.used_constants.insert(name.clone()) {
worklist.push_back(name.clone());
}
}
while let Some(name) = worklist.pop_front() {
if let Some(deps) = self.constant_deps.get(&name) {
for dep in deps {
if self.used_constants.insert(dep.clone()) {
worklist.push_back(dep.clone());
}
}
}
}
}

/// Define a new constant `constant`
///
/// Returns `Err` if a constant with the same name is already defined
Expand Down Expand Up @@ -133,6 +225,7 @@ impl AnalysisContext {
let constants = self.constants.keys().cloned().collect::<Vec<_>>();

for constant in constants.iter() {
self.simplifying_constant = Some(constant.clone());
let expr = ConstantExpr::Var(Span::new(
constant.span(),
PathBuf::from(constant.clone()).into(),
Expand All @@ -145,6 +238,7 @@ impl AnalysisContext {
self.errors.push(err);
},
}
self.simplifying_constant = None;
}
}

Expand Down
6 changes: 6 additions & 0 deletions crates/assembly-syntax/src/sema/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ pub enum SemanticAnalysisError {
#[label]
span: SourceSpan,
},
#[error("unused constant")]
#[diagnostic(severity(Warning), help("this constant is never used and can be safely removed"))]
UnusedConstant {
#[label]
span: SourceSpan,
},
#[error("missing import: the referenced module has not been imported")]
#[diagnostic()]
MissingImport {
Expand Down
16 changes: 15 additions & 1 deletion crates/assembly-syntax/src/sema/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,25 @@ pub fn analyze(
// Run item checks
visit_items(&mut module, &mut analyzer)?;

// Check unused imports
// Check unused constants
analyzer.resolve_constant_usage();

// Apply deferred import references from live constants, then check unused
// imports. This must happen after constant liveness is resolved so that
// imports reached only from dead constants are correctly reported as unused.
analyzer.apply_live_constant_import_refs(&mut module);
for import in module.aliases() {
if !import.is_used() {
analyzer.error(SemanticAnalysisError::UnusedImport { span: import.span() });
}
}

for constant in module.constants() {
if !analyzer.is_constant_used(constant) {
analyzer.error(SemanticAnalysisError::UnusedConstant { span: constant.span });
}
}

analyzer.into_result().map(move |_| module)
}

Expand Down Expand Up @@ -240,6 +252,7 @@ fn visit_items(module: &mut Module, analyzer: &mut AnalysisContext) -> Result<()
log::debug!(target: "verify-invoke", "visiting constant {}", constant.name());
{
let mut visitor = VerifyInvokeTargets::new(analyzer, module, &locals, None);
visitor.set_current_constant(Some(constant.name.clone()));
let _ = visitor.visit_mut_constant(&mut constant);
}
module.items.push(Export::Constant(constant));
Expand Down Expand Up @@ -328,6 +341,7 @@ fn add_advice_map_entry(
ConstantExpr::Word(Span::new(entry.span, WordValue(*key))),
);
context.define_constant(module, cst);
context.mark_constant_used(&entry.name);
match module.advice_map.get(&key) {
Some(_) => {
context.error(SemanticAnalysisError::AdvMapKeyAlreadyDefined { span: entry.span });
Expand Down
38 changes: 33 additions & 5 deletions crates/assembly-syntax/src/sema/passes/verify_invoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub struct VerifyInvokeTargets<'a> {
module: &'a mut Module,
procedures: &'a BTreeSet<Ident>,
current_procedure: Option<ProcedureName>,
/// When visiting a constant export, holds the constant's name so that
/// import references can be deferred until constant liveness is resolved.
current_constant: Option<Ident>,
invoked: BTreeSet<Invoke>,
}

Expand All @@ -39,9 +42,15 @@ impl<'a> VerifyInvokeTargets<'a> {
module,
procedures,
current_procedure,
current_constant: None,
invoked: Default::default(),
}
}

/// Set the constant name whose export is currently being visited.
pub fn set_current_constant(&mut self, name: Option<Ident>) {
self.current_constant = name;
}
}

impl VerifyInvokeTargets<'_> {
Expand Down Expand Up @@ -110,6 +119,11 @@ impl VerifyInvokeTargets<'_> {
}
}
fn track_used_alias(&mut self, name: &Ident) {
// A locally-defined constant with the same name shadows any import of that name,
// so the import should not be credited as used in that case.
if self.analyzer.get_constant(name).is_ok() {
return;
}
if let Some(alias) = self.module.aliases_mut().find(|a| a.name() == name) {
alias.uses += 1;
}
Expand Down Expand Up @@ -281,11 +295,25 @@ impl VisitMut for VerifyInvokeTargets<'_> {
}
fn visit_mut_constant_ref(&mut self, path: &mut Span<Arc<Path>>) -> ControlFlow<()> {
if let Some(name) = path.as_ident() {
self.track_used_alias(&name);
} else if let Some((module, _)) = path.split_first()
&& let Some(alias) = self.module.aliases_mut().find(|a| a.name().as_str() == module)
{
alias.uses += 1;
if let Some(ref const_name) = self.current_constant {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we check that name is actually an import before recording it here?

Local constants can reuse an imported alias name: use lib::a::FOO plus const FOO = 1 makes get() resolve FOO as the local constant, but this branch still records FOO as an import ref. If a live constant references that local FOO, apply_live_constant_import_refs() increments the import use and hides the unused-import warning for the shadowed import.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 3e09839.

The guard is in two places in verify_invoke.rs:

  1. track_used_alias — now calls analyzer.get_constant(name) first and returns early if the name resolves to a local constant, so a same-named import is never credited as used.

  2. visit_mut_constant_ref (identifier branch) — the deferred record_constant_import_ref call is now also skipped when the identifier resolves to a local constant, so dead-constant→shadowed-import edges don't survive into liveness resolution either.

Added a regression test (local_constant_shadowing_import_warns_unused_import) that assembles a module with both use lib::a::FOO and const FOO = 1 and asserts the import is reported unused.

// Only defer as an import ref if this identifier is not a local constant.
// A local constant shadows any same-named import, so the import gets no credit.
if self.analyzer.get_constant(&name).is_err() {
self.analyzer.record_constant_import_ref(const_name, name.as_str().into());
}
} else {
self.track_used_alias(&name);
}
} else if let Some((module, _)) = path.split_first() {
if let Some(ref const_name) = self.current_constant {
// Defer: record the edge so we only credit the import when this
// constant is proven live.
self.analyzer.record_constant_import_ref(const_name, module.into());
} else if let Some(alias) =
self.module.aliases_mut().find(|a| a.name().as_str() == module)
{
alias.uses += 1;
}
}
ControlFlow::Continue(())
}
Expand Down
Loading
Loading