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
59 changes: 58 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,13 @@ 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>>,
/// 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 +45,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 +82,9 @@ 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(),
simplifying_constant: None,
imported: Default::default(),
procedures: Default::default(),
errors: Default::default(),
Expand Down Expand Up @@ -98,6 +116,43 @@ 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());
}

/// 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() {
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 +188,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 +201,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
9 changes: 9 additions & 0 deletions crates/assembly-syntax/src/sema/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ pub fn analyze(
}
}

// Check unused constants
analyzer.resolve_constant_usage();
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.

I could repro a small mismatch here with a local test. A program shaped like

use lib::a
const DEAD = a::BAR
begin push.1 end

only emits unused constant when warnings are promoted to errors, and it skips the matching unused import warning.

The reason seems to be that visit_mut_constant_ref() increments alias.uses before this liveness pass proves the referencing constant is live, so imports reached only from dead constants stay marked as used.

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.

Nice catch - fixed in fda2ca5. The visit_mut_constant_ref path in VerifyInvokeTargets was bumping alias.uses unconditionally, so imports reached only through dead constants slipped past the unused-import check.

Now constant exports record their import references as deferred edges in AnalysisContext instead of incrementing directly. After resolve_constant_usage() runs, only imports from live constants get credited. Moved the unused-import check after constant liveness resolution too.

Added dead_constant_does_not_mask_unused_import to cover the exact scenario you described.

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 @@ -328,6 +336,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
123 changes: 123 additions & 0 deletions crates/assembly/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5789,3 +5789,126 @@ fn test_linking_recursive_expansion_via_renamed_aliases() -> TestResult {

Ok(())
}

// UNUSED CONSTANTS
// ================================================================================================

#[test]
fn unused_constant_warning() {
let context = TestContext::default();
let source = source_file!(
&context,
"
const UNUSED = 42

begin
push.1
end"
);

assert_assembler_diagnostic!(
context,
source,
"syntax error",
"help: see emitted diagnostics for details",
"unused constant",
regex!(r#",-\[test[\d]+:2:9\]"#),
"1 |",
"2 | const UNUSED = 42",
" : ^^^^^^^^^^^^^^^^^",
"3 |",
" `----",
" help: this constant is never used and can be safely removed"
);
}

#[test]
fn used_constant_no_warning() -> TestResult {
let context = TestContext::default();
let source = source_file!(
&context,
"
const MY_CONST = 42

begin
push.MY_CONST
end"
);

let _program = context.assemble(source)?;
Ok(())
}

#[test]
fn public_constant_no_warning() -> TestResult {
let context = TestContext::default();
let source = source_file!(
&context,
"
pub const EXPORTED = 42

pub proc foo
push.1
end"
);

let module = context.parse_module_with_path("test::lib", source)?;
let _library = context.assemble_library([module])?;
Ok(())
}

#[test]
fn chained_unused_constants_both_warn() {
let context = TestContext::default();
let source = source_file!(
&context,
"
const A = 1
const B = A

begin
push.1
end"
);

assert_assembler_diagnostic!(
context,
source,
"syntax error",
"help: see emitted diagnostics for details",
"unused constant",
regex!(r#",-\[test[\d]+:2:9\]"#),
"1 |",
"2 | const A = 1",
" : ^^^^^^^^^^^",
"3 |",
" `----",
" help: this constant is never used and can be safely removed",
"unused constant",
regex!(r#",-\[test[\d]+:3:9\]"#),
"2 |",
"3 | const B = A",
" : ^^^^^^^^^^^",
"4 |",
" `----",
" help: this constant is never used and can be safely removed"
);
}

#[test]
fn chained_constant_used_transitively() -> TestResult {
let context = TestContext::default();
let source = source_file!(
&context,
"
const A = 1
const B = A

begin
push.B
end"
);

let _program = context.assemble(source)?;
Ok(())
}
24 changes: 24 additions & 0 deletions crates/lib/core/asm/stark/constants.masm
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,30 @@ pub proc get_procedure_digest_process_public_inputs_ptr
push.DYNAMIC_PROCEDURE_3_PTR
end

pub proc get_num_fixed_len_public_inputs
push.NUM_FIXED_LEN_PUBLIC_INPUTS
end

pub proc get_kernel_op_label
push.KERNEL_OP_LABEL
end

pub proc num_fixed_len_public_inputs_ptr
push.NUM_FIXED_LEN_PUBLIC_INPUTS_PTR
end

pub proc num_ace_inputs_ptr
push.NUM_ACE_INPUTS_PTR
end

pub proc num_ace_gates_ptr
push.NUM_ACE_GATES_PTR
end

pub proc max_cycle_len_log_ptr
push.MAX_CYCLE_LEN_LOG_PTR
end

# HELPER
# =================================================================================================

Expand Down
Loading