use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use base64::Engine;
use clap::{Arg, ArgMatches, Command};
use serde_json::{Value, json};
use crate::{
CliCommand,
compliance::checks::run_local_checks,
constants::{get_platform_ui_url, get_studio_orchestrator_api_url},
core::{
command::command,
hmac::AuthMode,
http_client::{get_with_auth, post_with_auth},
report_card::{ReportCard, build_local_report_card, iso8601_now},
validate::require_manifest,
workspace_archive::pack_workspace,
},
};
const POLL_TIMEOUT: Duration = Duration::from_secs(15 * 60);
const POLL_INTERVAL: Duration = Duration::from_secs(3);
#[derive(Debug)]
pub(crate) struct ScoreCommand;
impl ScoreCommand {
pub(crate) fn new() -> Self {
Self
}
}
impl CliCommand for ScoreCommand {
fn command(&self) -> Command {
command(
"score",
"Score this workspace's enterprise readiness and get a shareable report card.",
)
.arg(
Arg::new("base_path")
.short('p')
.long("path")
.help("Application root path (defaults to the current directory's manifest)"),
)
.arg(
Arg::new("offline")
.long("offline")
.help(
"Score from deterministic checks only — no upload, no auth, no cost. Covers \
the compliance, security and governance checklist items those checks \
decide; scalability and observability need an agent and come back \
unassessed.",
)
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("no_share")
.long("no-share")
.help("Skip minting the share link (the card is still scored and printed)")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("json")
.long("json")
.help("Emit the raw report card as JSON instead of a terminal summary")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("pretty")
.long("pretty")
.help("Pretty-print the JSON output")
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("prompt")
.long("prompt")
.help(
"Print only the remediation prompt (the default output shows it after the summary): the \
same prompt the website's \"Generate prompt\" builds (every finding \
with its evidence and fix, the scored checklist, and the rescoring \
loop). With --offline, a prompt built from the deterministic checks.",
)
.action(clap::ArgAction::SetTrue),
)
.arg(
Arg::new("min_score")
.long("min-score")
.help("Exit non-zero if the overall score is below this (0-100). For CI gating.")
.value_parser(clap::value_parser!(u32).range(0..=100)),
)
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
let (app_root, manifest) = require_manifest(matches)?;
let json_out = matches.get_flag("json");
let pretty = matches.get_flag("pretty");
let prompt_out = matches.get_flag("prompt");
let offline = matches.get_flag("offline");
let (card_json, share_url, remote_prompt) = if offline {
(
offline_card(&app_root, &manifest.app_name, &manifest.modules_path)?,
None,
None,
)
} else {
score_via_api(
&app_root,
&manifest.app_name,
!matches.get_flag("no_share"),
json_out || prompt_out,
)?
};
let prompt = remote_prompt.unwrap_or_else(|| offline_prompt(&card_json, offline));
if json_out {
let mut out = card_json.clone();
if let Some(object) = out.as_object_mut() {
object.insert("remediationPrompt".to_string(), Value::String(prompt.clone()));
}
let serialized = if pretty {
serde_json::to_string_pretty(&out)?
} else {
serde_json::to_string(&out)?
};
println!("{}", serialized);
} else if prompt_out {
println!("{prompt}");
} else {
print_summary(&card_json);
if let Some(url) = &share_url {
println!(" {}", bold(&format!("Report card: {url}")));
println!();
}
println!(" {}", bold("Remediation prompt"));
println!(
" {}",
dim("(`forklaunch score --prompt` prints only this; `--json` carries it as remediationPrompt)")
);
println!();
println!("{prompt}");
}
if let Some(min) = matches.get_one::<u32>("min_score") {
let overall = card_json
.get("overall")
.and_then(Value::as_u64)
.unwrap_or(0) as u32;
if overall < *min {
anyhow::bail!(
"readiness score {} is below the required minimum of {}",
overall,
min
);
}
}
Ok(())
}
}
fn offline_card(app_root: &Path, app_name: &str, modules_path: &str) -> Result<Value> {
let modules_root = app_root.join(modules_path);
let findings = run_local_checks(&modules_root)?;
let module_count = std::fs::read_dir(&modules_root)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.count()
})
.unwrap_or(0);
let card: ReportCard =
build_local_report_card(app_name, module_count, &findings, iso8601_now());
Ok(json!(card))
}
fn studio_url(path: &str) -> String {
format!(
"{}/studio-orchestrator/analysis{}",
get_studio_orchestrator_api_url(),
path
)
}
fn score_via_api(
app_root: &Path,
app_name: &str,
want_share: bool,
quiet: bool,
) -> Result<(Value, Option<String>, Option<String>)> {
let auth = AuthMode::detect();
if !quiet {
println!();
println!(" {} packing workspace…", dim("→"));
}
let (archive, summary) = pack_workspace(app_root)?;
if !quiet {
println!(
" {} {} files, {:.1} MB{}",
dim("→"),
summary.files,
summary.bytes as f64 / (1024.0 * 1024.0),
if summary.skipped_large > 0 {
format!(" ({} large file(s) skipped)", summary.skipped_large)
} else {
String::new()
}
);
}
let zip_base64 = base64::engine::general_purpose::STANDARD.encode(&archive);
let body = json!({ "appId": app_name, "zipBase64": zip_base64 });
if !quiet {
println!(" {} uploading for analysis…", dim("→"));
}
let response = post_with_auth(&auth, &studio_url("/zip"), body)
.context("could not reach the analysis API")?;
let status = response.status();
if !status.is_success() {
let text = response.text().unwrap_or_default();
return Err(api_error(status.as_u16(), &text));
}
let accepted: Value = response.json().context("analysis API returned no job id")?;
let job_id = accepted
.get("jobId")
.and_then(Value::as_str)
.context("analysis API returned no job id")?
.to_string();
let job = poll_job(&auth, &job_id, quiet)?;
let card = job
.get("reportCard")
.cloned()
.context("the analysis finished without producing a report card")?;
let prompt = job
.get("remediationPrompt")
.and_then(Value::as_str)
.map(str::to_string);
let share_url = if want_share {
mint_share(&auth, &job_id).unwrap_or(None)
} else {
None
};
Ok((card, share_url, prompt))
}
fn offline_prompt(card: &Value, offline: bool) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let overall = card.get("overall").and_then(Value::as_u64).unwrap_or(0);
let _ = writeln!(
out,
"Harden this repository against its ForkLaunch report card{}.",
if offline { " (offline: deterministic wiring checks only)" } else { "" }
);
let _ = writeln!(
out,
"Baseline: {overall}/100. {}",
card.get("headline").and_then(Value::as_str).unwrap_or("")
);
if let Some(caveat) = card.get("caveat").and_then(Value::as_str) {
let _ = writeln!(out, "Caveat: {caveat}");
}
let _ = writeln!(
out,
"\nLOOP\n\
1. Fix the findings below in severity order. Inspect the code before accepting a finding, \
fix every place a shared cause appears, and add a test for each fix.\n\
2. After each batch run `forklaunch score --offline --json` (fast, free, deterministic) and \
confirm the finding is gone. A score that does not move means the fix did not take effect.\n\
3. When the offline checks are clean, run `forklaunch score --prompt --no-share` for the full \
assessment (every category, judged criteria) and continue from the prompt it prints. The \
offline score covers only the deterministic checks; do not stop at it.\n\
4. Never weaken a control, suppress a finding or remove functionality to raise the score."
);
let severity_rank = |s: &str| match s {
"critical" => 0,
"high" => 1,
"medium" => 2,
"low" => 3,
_ => 4,
};
let mut findings: Vec<(String, &Value)> = Vec::new();
let mut outstanding: Vec<(String, &Value)> = Vec::new();
if let Some(dimensions) = card.get("dimensions").and_then(Value::as_object) {
for (rail, dim) in dimensions {
for f in dim.get("findings").and_then(Value::as_array).into_iter().flatten() {
findings.push((rail.clone(), f));
}
for item in dim.get("items").and_then(Value::as_array).into_iter().flatten() {
if matches!(
item.get("status").and_then(Value::as_str),
Some("unmet" | "pending")
) {
outstanding.push((rail.clone(), item));
}
}
}
}
findings.sort_by_key(|(_, f)| severity_rank(f.get("severity").and_then(Value::as_str).unwrap_or("")));
let _ = writeln!(out, "\nFINDINGS");
if findings.is_empty() {
let _ = writeln!(out, "- None from these checks.");
}
let text = |v: &Value, key: &str| v.get(key).and_then(Value::as_str).unwrap_or("").to_string();
let mut grouped: Vec<(&String, &Value, usize)> = Vec::new();
for (rail, f) in &findings {
match grouped.iter_mut().find(|(r, g, _)| {
*r == rail && text(g, "title") == text(f, "title") && text(g, "detail") == text(f, "detail")
}) {
Some(entry) => entry.2 += 1,
None => grouped.push((rail, f, 1)),
}
}
for (rail, f, count) in &grouped {
let times = if *count > 1 { format!(" (x{count})") } else { String::new() };
let _ = writeln!(out, "- [{rail}; {}] {}{times}", text(f, "severity"), text(f, "title"));
let detail = text(f, "detail");
if !detail.is_empty() {
let _ = writeln!(out, " Evidence: {detail}");
}
let fix = text(f, "fix");
let _ = writeln!(
out,
" Remediation: {}",
if fix.is_empty() { "Inspect the cited code, find the root cause and repair it with a regression test." } else { &fix }
);
}
let _ = writeln!(out, "\nOUTSTANDING CHECKLIST ITEMS");
if outstanding.is_empty() {
let _ = writeln!(out, "- None.");
}
for (rail, item) in &outstanding {
let _ = writeln!(out, "- [{rail}] {}: {}", text(item, "label"), text(item, "status"));
let detail = text(item, "detail");
if !detail.is_empty() {
let _ = writeln!(out, " Evidence: {detail}");
}
}
out
}
fn poll_job(auth: &AuthMode, job_id: &str, quiet: bool) -> Result<Value> {
let started = Instant::now();
let mut last_status = String::new();
loop {
if started.elapsed() > POLL_TIMEOUT {
anyhow::bail!(
"analysis did not finish within {} minutes. It may still complete — check the \
dashboard, or poll job {job_id} directly.",
POLL_TIMEOUT.as_secs() / 60
);
}
let response = get_with_auth(auth, &studio_url(&format!("/job/{job_id}")))
.context("lost contact with the analysis API")?;
let http_status = response.status();
if !http_status.is_success() {
let text = response.text().unwrap_or_default();
return Err(api_error(http_status.as_u16(), &text));
}
let job: Value = response.json().context("could not read the job status")?;
let status = job.get("status").and_then(Value::as_str).unwrap_or("");
match status {
"done" => return Ok(job),
"error" => {
let message = job
.get("error")
.and_then(Value::as_str)
.unwrap_or("the analysis failed without a reason");
anyhow::bail!("analysis failed: {message}");
}
other => {
if !quiet && other != last_status {
println!(" {} {other}…", dim("→"));
last_status = other.to_string();
}
}
}
std::thread::sleep(POLL_INTERVAL);
}
}
fn mint_share(auth: &AuthMode, job_id: &str) -> Result<Option<String>> {
let response = post_with_auth(auth, &studio_url("/share"), json!({ "jobId": job_id }))?;
if !response.status().is_success() {
return Ok(None);
}
let body: Value = response.json()?;
Ok(body
.get("sharePath")
.and_then(Value::as_str)
.map(|path| format!("{}{}", get_platform_ui_url(), path)))
}
fn api_error(status: u16, body: &str) -> anyhow::Error {
match status {
401 => anyhow::anyhow!(
"not signed in. Run `forklaunch login`, or use `forklaunch score --offline` for the \
deterministic checks without an account."
),
402 => anyhow::anyhow!(
"analysis credits exhausted for this account. Add credits, or use \
`forklaunch score --offline` for the deterministic checks."
),
413 => anyhow::anyhow!(
"the workspace archive was rejected as too large. Exclude generated directories or \
score a narrower path with --path."
),
_ => anyhow::anyhow!("analysis API returned {status} — {body}"),
}
}
fn print_summary(card: &Value) {
print!("{}", render_summary(card));
}
fn render_summary(card: &Value) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let overall = card.get("overall").and_then(Value::as_u64).unwrap_or(0);
let _ = writeln!(out);
let _ = writeln!(out, " {} {}/100", bold("Enterprise Readiness"), overall);
if let Some(headline) = card.get("headline").and_then(Value::as_str) {
let _ = writeln!(out, " {}", dim(headline));
}
let _ = writeln!(out);
if let Some(dimensions) = card.get("dimensions").and_then(Value::as_object) {
let mut keys: Vec<&String> = dimensions.keys().collect();
keys.sort();
for key in keys {
let rail = &dimensions[key];
let label = {
let mut c = key.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => key.clone(),
}
};
if rail.get("pending").and_then(Value::as_bool).unwrap_or(false) {
let _ = writeln!(out, " {:<16} {}", label, dim("not assessed"));
continue;
}
let score = rail.get("score").and_then(Value::as_u64).unwrap_or(0);
let items = rail.get("items").and_then(Value::as_array);
let outstanding = items
.map(|items| {
items
.iter()
.filter(|item| {
matches!(
item.get("status").and_then(Value::as_str),
Some("unmet" | "pending")
)
})
.count()
})
.unwrap_or(0);
if outstanding > 0 {
let _ = writeln!(
out,
" {:<16} {}/100 {}",
label,
score,
dim(&format!(
"({} item{} outstanding)",
outstanding,
if outstanding == 1 { "" } else { "s" }
))
);
} else {
let _ = writeln!(out, " {:<16} {}/100", label, score);
}
if let Some(items) = items {
for item in items.iter().take(8) {
let marker = match item.get("status").and_then(Value::as_str) {
Some("met") => "+",
Some("pending") => "?",
_ => "-",
};
let text = item.get("label").and_then(Value::as_str).unwrap_or("");
let _ = writeln!(out, " {} {}", marker, text);
}
}
if let Some(findings) = rail.get("findings").and_then(Value::as_array)
&& !findings.is_empty()
{
let mut unique: Vec<(&str, usize)> = Vec::new();
for finding in findings {
let title = finding.get("title").and_then(Value::as_str).unwrap_or("");
match unique.iter_mut().find(|(seen, _)| *seen == title) {
Some((_, count)) => *count += 1,
None => unique.push((title, 1)),
}
}
let _ = writeln!(
out,
" {}",
dim(&format!(
"{} finding(s){}",
unique.len(),
if unique.len() == findings.len() {
String::new()
} else {
format!(", {} total", findings.len())
}
))
);
for (title, count) in unique.iter().take(5) {
if title.is_empty() {
continue;
}
let suffix = if *count > 1 {
format!(" (×{count})")
} else {
String::new()
};
let _ = writeln!(out, " {}", dim(&format!("{title}{suffix}")));
}
}
let _ = writeln!(out);
}
}
if let Some(caveat) = card.get("caveat").and_then(Value::as_str) {
let _ = writeln!(out, " {}", dim(caveat));
let _ = writeln!(out);
}
out
}
fn bold(s: &str) -> String {
format!("\x1b[1m{s}\x1b[0m")
}
fn dim(s: &str) -> String {
format!("\x1b[2m{s}\x1b[0m")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auth_failures_explain_the_tier_rather_than_the_status_code() {
let unauthorised = api_error(401, "{}").to_string();
assert!(unauthorised.contains("forklaunch login"), "{unauthorised}");
assert!(unauthorised.contains("--offline"), "{unauthorised}");
let payment = api_error(402, "{}").to_string();
assert!(payment.contains("credits"), "{payment}");
assert!(payment.contains("--offline"), "{payment}");
}
#[test]
fn an_unexpected_status_keeps_the_body_rather_than_swallowing_it() {
let err = api_error(500, "boom").to_string();
assert!(err.contains("500"), "{err}");
assert!(err.contains("boom"), "{err}");
}
#[test]
fn the_studio_path_matches_what_the_dashboard_calls() {
assert!(studio_url("/zip").ends_with("/studio-orchestrator/analysis/zip"));
assert!(studio_url("/share").ends_with("/studio-orchestrator/analysis/share"));
}
#[test]
fn summary_renders_a_card_without_panicking_on_missing_fields() {
print_summary(&json!({}));
print_summary(&json!({
"overall": 72,
"dimensions": { "security": { "score": 80, "items": [], "findings": [] } }
}));
}
#[test]
fn a_perfect_rail_still_reports_its_unmet_items() {
let rendered = render_summary(&json!({
"overall": 100,
"dimensions": {
"compliance": {
"score": 100,
"items": [
{ "status": "met", "label": "Sensitive fields are encrypted at rest" },
{ "status": "pending", "label": "Sensitive data is identified and handled according to its sensitivity" }
],
"findings": []
}
}
}));
assert!(rendered.contains("100/100"), "{rendered}");
assert!(rendered.contains("1 item outstanding"), "{rendered}");
assert!(
rendered.contains("? Sensitive data is identified"),
"a review item must not render as met or failed: {rendered}"
);
}
#[test]
fn a_rail_with_nothing_outstanding_says_nothing_extra() {
let rendered = render_summary(&json!({
"overall": 100,
"dimensions": {
"security": {
"score": 100,
"items": [{ "status": "met", "label": "One customer cannot see or change another customer\u{2019}s data" }],
"findings": []
}
}
}));
assert!(!rendered.contains("outstanding"), "{rendered}");
}
#[test]
fn repeated_findings_collapse_to_one_line_with_a_count() {
let repeated: Vec<serde_json::Value> = (0..7)
.map(|_| json!({ "title": "Sensitive fields are classified (iam)" }))
.collect();
let rendered = render_summary(&json!({
"overall": 100,
"dimensions": {
"compliance": { "score": 100, "items": [], "findings": repeated }
}
}));
assert!(rendered.contains("1 finding(s), 7 total"), "{rendered}");
assert!(rendered.contains("(×7)"), "{rendered}");
}
#[test]
fn distinct_findings_are_not_collapsed() {
let rendered = render_summary(&json!({
"overall": 60,
"dimensions": {
"compliance": {
"score": 60,
"items": [],
"findings": [
{ "title": "Field encryptor is not registered" },
{ "title": "Retention policies are missing" }
]
}
}
}));
assert!(rendered.contains("2 finding(s)"), "{rendered}");
assert!(!rendered.contains("total"), "{rendered}");
}
#[test]
fn a_pending_rail_is_not_given_a_score() {
let rendered = render_summary(&json!({
"overall": 100,
"dimensions": { "governance": { "score": 0, "pending": true } }
}));
assert!(rendered.contains("not assessed"), "{rendered}");
let governance_line = rendered
.lines()
.find(|line| line.contains("Governance"))
.expect("governance rail should render");
assert!(!governance_line.contains("/100"), "{governance_line}");
assert!(!governance_line.contains("outstanding"), "{governance_line}");
}
}
#[cfg(test)]
mod prompt_tests {
use super::offline_prompt;
use serde_json::json;
fn card() -> serde_json::Value {
json!({
"overall": 72,
"headline": "demo",
"dimensions": {
"security": {
"findings": [
{"severity": "high", "title": "Tenant filter", "detail": "worker.ts lacks setupTenantFilter", "fix": "Call setupTenantFilter."},
{"severity": "critical", "title": "Direct AI provider", "detail": "src/ai.ts imports openai", "fix": "Use createModelGatewayClient."}
],
"items": [{"label": "Encryption at rest", "status": "unmet", "detail": "no key"}]
},
"compliance": {
"findings": [
{"severity": "info", "title": "Classify fields", "detail": "same"},
{"severity": "info", "title": "Classify fields", "detail": "same"}
],
"items": [{"label": "Audit log", "status": "met"}]
}
}
})
}
#[test]
fn prompt_carries_loop_evidence_and_fixes_in_severity_order() {
let p = offline_prompt(&card(), true);
assert!(p.contains("Baseline: 72/100"));
assert!(p.contains("forklaunch score --offline --json"));
assert!(p.contains("forklaunch score --prompt --no-share"));
assert!(p.contains("Remediation: Use createModelGatewayClient."));
assert!(p.contains("Evidence: worker.ts lacks setupTenantFilter"));
assert!(p.find("Direct AI provider").unwrap() < p.find("Tenant filter").unwrap());
assert!(p.contains("Encryption at rest: unmet"));
assert!(!p.contains("Audit log"));
}
#[test]
fn identical_findings_collapse_with_a_count() {
let p = offline_prompt(&card(), true);
assert_eq!(p.matches("Classify fields").count(), 1);
assert!(p.contains("Classify fields (x2)"));
assert!(p.contains("Remediation: Inspect the cited code"));
}
}