Skip to content

Commit fa51bd3

Browse files
authored
Merge pull request #473 from iammrjude/issue-367-contract-coverage-analysis
feat: add contract coverage analysis
2 parents 8182504 + 0d45c03 commit fa51bd3

39 files changed

Lines changed: 1925 additions & 439 deletions

docs/COMMAND_REFERENCE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ starforge contract generate-bindings ./token.wasm --lang rust
115115
| `--fixture <FILE>` | JSON/TOML contract test suite with fixtures, mocks, and assertions |
116116
| `--source <FILE>` | Contract source used for generated tests or coverage |
117117
| `--coverage` | Include source coverage summary |
118+
| `--coverage-out <FILE>` | Write a dedicated coverage report |
119+
| `--coverage-format html\|json\|markdown\|text` | Format for `--coverage-out` |
120+
| `--coverage-goal <PCT>` | Minimum overall coverage percentage |
121+
| `--function-coverage-goal <PCT>` | Minimum function coverage percentage |
122+
| `--line-coverage-goal <PCT>` | Minimum line coverage percentage |
123+
| `--branch-coverage-goal <PCT>` | Minimum branch coverage percentage |
124+
| `--coverage-ci` | Fail when configured coverage goals are missed |
125+
| `--coverage-ci-workflow-out <FILE>` | Generate a GitHub Actions coverage workflow |
118126
| `--report html\|json\|junit` | Write a test report (`junit` is available for fixture suites) |
119127
| `--testnet` | Validate Soroban testnet integration for the run |
120128
| `--testnet-dry-run` | Validate testnet configuration without probing RPC health |
@@ -123,11 +131,19 @@ starforge contract generate-bindings ./token.wasm --lang rust
123131
starforge test --wasm ./target/contract.wasm \
124132
--fixture ./contract-tests.json --coverage --source ./src/lib.rs --report html
125133

134+
starforge test --wasm ./target/contract.wasm --source ./src/lib.rs \
135+
--coverage --coverage-out coverage.html --coverage-format html \
136+
--coverage-ci --coverage-goal 85 --branch-coverage-goal 70
137+
138+
starforge test --wasm ./target/contract.wasm --source ./src/lib.rs \
139+
--coverage-ci-workflow-out .github/workflows/starforge-coverage.yml
140+
126141
starforge test --wasm ./target/contract.wasm \
127142
--fixture ./contract-tests.toml --testnet --testnet-dry-run
128143
```
129144

130145
Fixture suites support named storage fixtures, mocked contract calls, and assertions such as `state_equals`, `state_exists`, `return_equals`, `event_emitted`, `fee_at_most`, and `mock_called`.
146+
Coverage analysis tracks Soroban contract functions, line spans, branch paths, uncovered functions, threshold goals, and HTML/JSON/Markdown/text reports.
131147

132148
---
133149

src/commands/backup.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,10 @@ async fn handle_contract_state(args: ContractStateArgs) -> Result<()> {
423423
p::kv("Contract", &manifest.contract_id);
424424
p::kv("Network", &manifest.source_network);
425425
p::kv("Latest ledger", &manifest.latest_ledger.to_string());
426-
p::kv("State entries", &manifest.instance_storage.len().to_string());
426+
p::kv(
427+
"State entries",
428+
&manifest.instance_storage.len().to_string(),
429+
);
427430
p::kv("Checksum", &manifest.checksum);
428431
p::success("Contract state backup created and verified");
429432
Ok(())

src/commands/bridge.rs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
use crate::utils::bridge::{
2-
load_config, load_transfers, providers::{self, BridgeTransferRequest, TransferStatus},
3-
record_transfer, routes::RouteRegistry, save_config, security::SecurityVerifier,
4-
state::StateSynchronizer, monitoring::BridgeMonitor, BridgeConfig, BridgeTransferRecord,
2+
load_config, load_transfers,
3+
monitoring::BridgeMonitor,
4+
providers::{self, BridgeTransferRequest, TransferStatus},
5+
record_transfer,
6+
routes::RouteRegistry,
7+
save_config,
8+
security::SecurityVerifier,
9+
state::StateSynchronizer,
10+
BridgeConfig, BridgeTransferRecord,
511
};
612
use crate::utils::print as p;
713
use anyhow::Result;
@@ -255,7 +261,10 @@ fn handle_status(args: StatusArgs) -> Result<()> {
255261

256262
p::kv("Transfer ID", &record.id);
257263
p::kv("Status", &status.to_string());
258-
p::kv("Source", &format!("{} → {}", record.source_network, record.asset));
264+
p::kv(
265+
"Source",
266+
&format!("{} → {}", record.source_network, record.asset),
267+
);
259268
p::kv("Dest", &record.dest_network);
260269
p::kv("Amount", &record.amount.to_string());
261270
if let Some(ref tx) = record.tx_hash_source {
@@ -315,10 +324,11 @@ fn handle_routes(args: RoutesArgs) -> Result<()> {
315324
fn handle_configure(args: ConfigureArgs) -> Result<()> {
316325
let mut config = load_config()?;
317326

318-
if args.show || (!args.enable.is_some()
319-
&& args.default_provider.is_none()
320-
&& args.max_amount.is_none()
321-
&& args.require_proof.is_none())
327+
if args.show
328+
|| (args.enable.is_none()
329+
&& args.default_provider.is_none()
330+
&& args.max_amount.is_none()
331+
&& args.require_proof.is_none())
322332
{
323333
p::header("Bridge Configuration");
324334
p::kv("Enabled", &config.enabled.to_string());
@@ -371,7 +381,10 @@ fn handle_sync(args: SyncArgs) -> Result<()> {
371381
p::kv("Dest ledger", &args.dest_ledger.to_string());
372382
p::kv("In sync", &sync.is_in_sync(1000).to_string());
373383
p::kv("Pending", &sync.state().pending_transfers.len().to_string());
374-
p::kv("Completed", &sync.state().completed_transfers.len().to_string());
384+
p::kv(
385+
"Completed",
386+
&sync.state().completed_transfers.len().to_string(),
387+
);
375388
p::success("State synchronized");
376389
Ok(())
377390
}
@@ -431,7 +444,10 @@ fn handle_monitor(args: MonitorArgs) -> Result<()> {
431444
}
432445

433446
p::header("Bridge Monitoring");
434-
p::kv("Unacknowledged alerts", &monitor.unacknowledged_count().to_string());
447+
p::kv(
448+
"Unacknowledged alerts",
449+
&monitor.unacknowledged_count().to_string(),
450+
);
435451

436452
if args.json {
437453
println!("{}", serde_json::to_string_pretty(monitor.alerts())?);

src/commands/debug.rs

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,7 @@ async fn handle_start(args: StartArgs) -> Result<()> {
159159
}
160160
sess.set_variables(vars);
161161

162-
let mut frames = Vec::new();
163-
frames.push(debugger::StackFrame {
162+
let frames = vec![debugger::StackFrame {
164163
function: "contract_init".to_string(),
165164
contract_id: Some(cid.clone()),
166165
source_location: None,
@@ -173,7 +172,7 @@ async fn handle_start(args: StartArgs) -> Result<()> {
173172
value: e.value.clone(),
174173
})
175174
.collect(),
176-
});
175+
}];
177176
sess.set_call_stack(frames);
178177
sess.add_step_history("Debug session started".to_string());
179178
}
@@ -283,21 +282,33 @@ async fn handle_step(args: StepArgs) -> Result<()> {
283282
debugger.step_out();
284283
p::success("Stepping out of current function…");
285284
}
286-
_ => anyhow::bail!("Invalid step direction '{}'. Use 'into', 'over', or 'out'.", args.direction),
285+
_ => anyhow::bail!(
286+
"Invalid step direction '{}'. Use 'into', 'over', or 'out'.",
287+
args.direction
288+
),
287289
}
288290

289-
debugger.session.add_step_history(format!("step {}", args.direction));
291+
debugger
292+
.session
293+
.add_step_history(format!("step {}", args.direction));
290294

291-
simulate_step(debugger).await?;
295+
simulate_step(debugger)?;
292296

293297
Ok(())
294298
}
295299

296-
async fn simulate_step(debugger: &mut Debugger) -> Result<()> {
297-
let current_fn = debugger.session.current_function.clone().unwrap_or_default();
300+
fn simulate_step(debugger: &mut Debugger) -> Result<()> {
301+
let current_fn = debugger
302+
.session
303+
.current_function
304+
.clone()
305+
.unwrap_or_default();
298306

299307
p::separator();
300-
p::kv_accent("Current Depth", &debugger.session.call_stack.len().to_string());
308+
p::kv_accent(
309+
"Current Depth",
310+
&debugger.session.call_stack.len().to_string(),
311+
);
301312
p::kv_accent("Step Count", &debugger.session.step_count.to_string());
302313

303314
if !current_fn.is_empty() {
@@ -449,7 +460,11 @@ async fn handle_stack() -> Result<()> {
449460
if frames.is_empty() {
450461
p::info("Call stack is empty.");
451462
} else {
452-
p::header(&format!("Call Stack ({} frame{})", frames.len(), if frames.len() == 1 { "" } else { "s" }));
463+
p::header(&format!(
464+
"Call Stack ({} frame{})",
465+
frames.len(),
466+
if frames.len() == 1 { "" } else { "s" }
467+
));
453468
p::separator();
454469
for (i, frame) in frames.iter().enumerate() {
455470
let depth = frames.len() - i;
@@ -587,7 +602,12 @@ async fn handle_ui(args: UiArgs) -> Result<()> {
587602
async fn manage_breakpoints_interactive() -> Result<()> {
588603
let selection = Select::new()
589604
.with_prompt("Breakpoint Action")
590-
.items(&["List Breakpoints", "Add Breakpoint", "Remove Breakpoint", "Go Back"])
605+
.items(&[
606+
"List Breakpoints",
607+
"Add Breakpoint",
608+
"Remove Breakpoint",
609+
"Go Back",
610+
])
591611
.default(0)
592612
.interact()?;
593613

@@ -596,9 +616,7 @@ async fn manage_breakpoints_interactive() -> Result<()> {
596616
handle_breakpoint(BreakpointCommands::List).await?;
597617
}
598618
1 => {
599-
let function: String = Input::new()
600-
.with_prompt("Function name")
601-
.interact_text()?;
619+
let function: String = Input::new().with_prompt("Function name").interact_text()?;
602620
let contract_id: String = Input::new()
603621
.with_prompt("Contract ID (optional)")
604622
.allow_empty(true)
@@ -635,5 +653,3 @@ async fn manage_breakpoints_interactive() -> Result<()> {
635653
}
636654
Ok(())
637655
}
638-
639-

src/commands/deploy.rs

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,8 @@ pub async fn handle(args: DeployArgs) -> Result<()> {
334334
wasm_size_kb,
335335
wallet,
336336
&args.network,
337-
).await;
337+
)
338+
.await;
338339
}
339340

340341
if args.simulate {
@@ -422,15 +423,17 @@ pub async fn handle(args: DeployArgs) -> Result<()> {
422423
let pb = p::progress_bar(3, "Starting deployment steps...");
423424

424425
pb.set_message("Verifying account on-chain...");
425-
let account = horizon::fetch_account(&wallet.public_key, &args.network).await.map_err(|e| {
426-
pb.abandon();
427-
anyhow::anyhow!(
428-
"Account not active on {}: {}\nFund it with: starforge wallet fund {}",
429-
args.network,
430-
e,
431-
wallet.name
432-
)
433-
})?;
426+
let account = horizon::fetch_account(&wallet.public_key, &args.network)
427+
.await
428+
.map_err(|e| {
429+
pb.abandon();
430+
anyhow::anyhow!(
431+
"Account not active on {}: {}\nFund it with: starforge wallet fund {}",
432+
args.network,
433+
e,
434+
wallet.name
435+
)
436+
})?;
434437

435438
let xlm = account
436439
.balances
@@ -495,7 +498,12 @@ pub async fn handle(args: DeployArgs) -> Result<()> {
495498
p::error(&format!("Stellar CLI deployment failed: {}", stderr));
496499

497500
// Automatic rollback safety net: revert to the last good deployment.
498-
handle_failed_deploy_rollback(args.no_auto_rollback, previous, &wallet.name, &args.network)?;
501+
handle_failed_deploy_rollback(
502+
args.no_auto_rollback,
503+
previous,
504+
&wallet.name,
505+
&args.network,
506+
)?;
499507

500508
anyhow::bail!("Stellar CLI deployment failed: {}", stderr);
501509
}
@@ -567,12 +575,18 @@ mod tests {
567575
let id = format!("C{}", "A".repeat(55));
568576
assert_eq!(id.len(), 56);
569577
let stdout = format!("ℹ️ Simulating deploy...\nℹ️ Submitting...\n{}\n", id);
570-
assert_eq!(parse_contract_id_from_stdout(&stdout).as_deref(), Some(id.as_str()));
578+
assert_eq!(
579+
parse_contract_id_from_stdout(&stdout).as_deref(),
580+
Some(id.as_str())
581+
);
571582
}
572583

573584
#[test]
574585
fn returns_none_when_no_contract_id_present() {
575-
assert_eq!(parse_contract_id_from_stdout("deploy failed: timeout"), None);
586+
assert_eq!(
587+
parse_contract_id_from_stdout("deploy failed: timeout"),
588+
None
589+
);
576590
// A 56-char wallet public key (G...) must not be mistaken for a contract id.
577591
let gkey = format!("G{}", "A".repeat(55));
578592
assert_eq!(gkey.len(), 56);

src/commands/deployments.rs

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -305,20 +305,24 @@ async fn handle_verify(args: VerifyArgs) -> Result<()> {
305305
if let Some(wallet) = cfg.wallets.iter().find(|w| w.name == record.wallet) {
306306
match horizon::fetch_account(&wallet.public_key, &record.network).await {
307307
Ok(_) => {
308-
report.checks.push(crate::utils::deployment_verify::VerificationCheck {
309-
name: "wallet_active".to_string(),
310-
category: "functionality".to_string(),
311-
status: crate::utils::deployment_verify::CheckStatus::Passed,
312-
detail: format!("Wallet '{}' is active on-chain", record.wallet),
313-
});
308+
report
309+
.checks
310+
.push(crate::utils::deployment_verify::VerificationCheck {
311+
name: "wallet_active".to_string(),
312+
category: "functionality".to_string(),
313+
status: crate::utils::deployment_verify::CheckStatus::Passed,
314+
detail: format!("Wallet '{}' is active on-chain", record.wallet),
315+
});
314316
}
315317
Err(e) => {
316-
report.checks.push(crate::utils::deployment_verify::VerificationCheck {
317-
name: "wallet_active".to_string(),
318-
category: "functionality".to_string(),
319-
status: crate::utils::deployment_verify::CheckStatus::Warning,
320-
detail: format!("Could not verify wallet: {}", e),
321-
});
318+
report
319+
.checks
320+
.push(crate::utils::deployment_verify::VerificationCheck {
321+
name: "wallet_active".to_string(),
322+
category: "functionality".to_string(),
323+
status: crate::utils::deployment_verify::CheckStatus::Warning,
324+
detail: format!("Could not verify wallet: {}", e),
325+
});
322326
}
323327
}
324328
}
@@ -349,7 +353,10 @@ async fn handle_verify(args: VerifyArgs) -> Result<()> {
349353
.iter()
350354
.filter(|c| c.status == crate::utils::deployment_verify::CheckStatus::Passed)
351355
.count();
352-
p::kv("Checks passed", &format!("{}/{}", passed_count, report.checks.len()));
356+
p::kv(
357+
"Checks passed",
358+
&format!("{}/{}", passed_count, report.checks.len()),
359+
);
353360
}
354361

355362
if args.report || args.save {

src/commands/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
pub mod analytics;
22
pub mod audit;
33
pub mod backup;
4-
pub mod bridge;
54
pub mod benchmark;
5+
pub mod bridge;
66
pub mod command_tree;
77
pub mod completions;
88
pub mod config;

src/commands/monitor.rs

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -68,23 +68,29 @@ pub async fn handle(args: MonitorArgs) -> Result<()> {
6868
println!();
6969

7070
match (&args.contract, &args.wallet) {
71-
(Some(contract_id), None) => monitor_contract(
72-
contract_id,
73-
args.events.as_deref(),
74-
args.event_type.as_deref(),
75-
args.topic.as_deref(),
76-
args.value.as_deref(),
77-
network,
78-
args.interval,
79-
args.follow,
80-
).await,
81-
(None, Some(wallet_name)) => monitor_wallet(
82-
wallet_name,
83-
args.threshold,
84-
args.balance_alert,
85-
network,
86-
args.interval,
87-
).await,
71+
(Some(contract_id), None) => {
72+
monitor_contract(
73+
contract_id,
74+
args.events.as_deref(),
75+
args.event_type.as_deref(),
76+
args.topic.as_deref(),
77+
args.value.as_deref(),
78+
network,
79+
args.interval,
80+
args.follow,
81+
)
82+
.await
83+
}
84+
(None, Some(wallet_name)) => {
85+
monitor_wallet(
86+
wallet_name,
87+
args.threshold,
88+
args.balance_alert,
89+
network,
90+
args.interval,
91+
)
92+
.await
93+
}
8894
_ => anyhow::bail!("Specify either --contract or --wallet (but not both)"),
8995
}
9096
}

0 commit comments

Comments
 (0)