use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct RepoStats {
pub full_name: String,
pub stargazers_count: i64,
pub forks_count: i64,
#[allow(dead_code)]
pub open_issues_count: i64,
pub created_at: String,
#[serde(default)]
pub age_days: i64,
}
#[derive(Debug, Clone)]
pub struct AuditReport {
pub repo: String,
pub stats: RepoStats,
pub verdict: signalkit_integrity::ForkVerdict,
pub fork_ratio_pct: f64,
}
pub async fn fetch_repo_stats(repo: &str, token: Option<&str>) -> anyhow::Result<RepoStats> {
let client = fetch_kit::Client::builder()
.user_agent("skillkit/0.1")
.default_header("accept", "application/vnd.github+json")
.build();
let url = format!("https://api.github.com/repos/{repo}");
let mut request = client.get(&url);
if let Some(t) = token {
request = request.bearer_auth(t);
}
let mut stats: RepoStats = request.json_response().await?;
stats.age_days = age_in_days(&stats.created_at, current_epoch_days());
Ok(stats)
}
fn age_in_days(created_at: &str, now_epoch_days: i64) -> i64 {
let Some(day) = created_at.get(0..10) else {
return 0;
};
let mut parts = [1970i64, 1, 1];
for (i, chunk) in day.split('-').take(3).enumerate() {
if let Ok(v) = chunk.parse::<i64>() {
parts[i] = v;
}
}
now_epoch_days - days_from_epoch(parts[0], parts[1], parts[2])
}
fn days_from_epoch(y: i64, m: i64, d: i64) -> i64 {
let days_before_month = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
let y_days = (y - 1970) * 365 + (y - 1969) / 4 - (y - 1901) / 100 + (y - 1921) / 400;
y_days + days_before_month[(m - 1).clamp(0, 11) as usize] + d - 1
}
fn current_epoch_days() -> i64 {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
secs / 86_400
}
pub async fn audit_repo(
repo: &str,
min_stars: i64,
token: Option<&str>,
) -> anyhow::Result<AuditReport> {
let stats = fetch_repo_stats(repo, token).await?;
let fork_ratio_pct = if stats.stargazers_count > 0 {
stats.forks_count as f64 * 100.0 / stats.stargazers_count as f64
} else {
0.0
};
let verdict =
signalkit_integrity::fork_verdict(stats.stargazers_count, stats.forks_count, min_stars);
Ok(AuditReport {
repo: stats.full_name.clone(),
stats,
verdict,
fork_ratio_pct,
})
}
impl AuditReport {
#[must_use]
pub fn render(&self) -> String {
let verdict_line = match self.verdict {
signalkit_integrity::ForkVerdict::Normal => "normal — nothing unusual",
signalkit_integrity::ForkVerdict::Elevated => {
"ELEVATED — unusual fork ratio, worth a manual look"
}
signalkit_integrity::ForkVerdict::Extreme => {
"EXTREME — fork ratio matches farmed-engagement shapes"
}
};
format!(
"audit: {}\n stars: {} forks: {} (ratio {:.1}%)\n age: {} days\n fork-ratio verdict (min {} stars): {}\n evidence is heuristic; check commit history and contributor spread before drawing conclusions",
self.repo,
self.stats.stargazers_count,
self.stats.forks_count,
self.fork_ratio_pct,
self.stats.age_days,
200,
verdict_line
)
}
}
#[must_use]
pub fn parse_marketplace_repos(json: &str) -> (Vec<String>, usize) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return (Vec::new(), 0);
};
let Some(plugins) = value.get("plugins").and_then(|p| p.as_array()) else {
return (Vec::new(), 0);
};
let total = plugins.len();
let mut out: Vec<String> = Vec::new();
for plugin in plugins {
let candidate = match plugin.get("source") {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(source) => source
.get("repo")
.and_then(|r| r.as_str())
.map(str::to_owned)
.or_else(|| {
source
.get("url")
.and_then(|u| u.as_str())
.and_then(github_repo_from_url)
}),
None => None,
};
if let Some(repo) = candidate {
let parts: Vec<&str> = repo.split('/').collect();
if parts.len() == 2
&& parts.iter().all(|p| !p.is_empty())
&& repo
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-._/".contains(c))
&& !out.contains(&repo)
{
out.push(repo);
}
}
}
(out, total)
}
fn github_repo_from_url(url: &str) -> Option<String> {
let rest = url.strip_prefix("https://github.com/")?;
let mut parts = rest.split('/');
let owner = parts.next()?;
let repo = parts.next()?;
let repo = repo.strip_suffix(".git").unwrap_or(repo);
if owner.is_empty() || repo.is_empty() {
return None;
}
Some(format!("{owner}/{repo}"))
}
pub async fn fetch_marketplace_json(repo: &str, token: Option<&str>) -> anyhow::Result<String> {
let client = fetch_kit::Client::builder()
.user_agent("skillkit/0.1")
.default_header("accept", "application/vnd.github+json")
.build();
for path in [".claude-plugin/marketplace.json", "marketplace.json"] {
let url = format!("https://api.github.com/repos/{repo}/contents/{path}");
let mut request = client.get(&url);
if let Some(t) = token {
request = request.bearer_auth(t);
}
let response: serde_json::Value = match request.json_response().await {
Ok(v) => v,
Err(_) => continue,
};
if let Some(content) = response.get("content").and_then(|c| c.as_str()) {
let cleaned: String = content.chars().filter(|c| !c.is_whitespace()).collect();
use base64::Engine;
let decoded = base64::engine::general_purpose::STANDARD
.decode(cleaned)
.map_err(|e| anyhow::anyhow!("marketplace base64 decode failed: {e}"))?;
return Ok(String::from_utf8_lossy(&decoded).into_owned());
}
}
anyhow::bail!("no marketplace.json found in {repo}")
}
pub struct BatchReport {
pub audited: Vec<AuditReport>,
pub failed: Vec<(String, String)>,
}
impl BatchReport {
#[must_use]
pub fn render(&self) -> String {
let mut out = String::new();
let elevated = self
.audited
.iter()
.filter(|r| r.verdict == signalkit_integrity::ForkVerdict::Elevated)
.count();
let extreme = self
.audited
.iter()
.filter(|r| r.verdict == signalkit_integrity::ForkVerdict::Extreme)
.count();
out.push_str(&format!(
"batch audit: {} audited, {} elevated, {} extreme, {} failed\n",
self.audited.len(),
elevated,
extreme,
self.failed.len()
));
out.push_str("Verdicts are heuristic fork-ratio signals; evidence below.\n");
let mut rows: Vec<&AuditReport> = self.audited.iter().collect();
rows.sort_by_key(|r| std::cmp::Reverse((r.fork_ratio_pct * 100.0) as i64));
for r in rows {
let tag = match r.verdict {
signalkit_integrity::ForkVerdict::Extreme => "EXTREME",
signalkit_integrity::ForkVerdict::Elevated => "elevated",
signalkit_integrity::ForkVerdict::Normal => "normal",
};
out.push_str(&format!(
" {:<8} {:>7}\u{2605} {:>6} forks ({:>5.1}%) {}\n",
tag, r.stats.stargazers_count, r.stats.forks_count, r.fork_ratio_pct, r.repo
));
}
for (repo, err) in &self.failed {
out.push_str(&format!(" failed {repo}: {err}\n"));
}
out
}
}
pub async fn audit_batch(
body: &str,
min_stars: i64,
token: Option<&str>,
) -> anyhow::Result<BatchReport> {
let (repos, total_entries) = parse_marketplace_repos(body);
if repos.is_empty() {
anyhow::bail!("no auditable GitHub repos found in marketplace index");
}
let mut audited = Vec::new();
let mut failed = Vec::new();
for repo in &repos {
match fetch_repo_stats(repo, token).await {
Ok(stats) => {
let fork_ratio_pct =
stats.forks_count as f64 * 100.0 / stats.stargazers_count.max(1) as f64;
let verdict = signalkit_integrity::fork_verdict(
stats.stargazers_count,
stats.forks_count,
min_stars,
);
audited.push(AuditReport {
repo: stats.full_name.clone(),
stats,
verdict,
fork_ratio_pct,
});
}
Err(err) => failed.push((repo.clone(), err.to_string())),
}
}
Ok(BatchReport { audited, failed })
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn age_parses_rfc3339_prefix() {
let now = days_from_epoch(2026, 10, 18);
assert_eq!(age_in_days("2026-10-07T08:34:24Z", now), 11);
assert_eq!(age_in_days("garbage", now), 0);
assert_eq!(days_from_epoch(1970, 1, 1), 0);
assert_eq!(days_from_epoch(2026, 10, 18), now);
}
#[test]
fn render_shows_evidence() {
let report = AuditReport {
repo: "farm/thing".to_owned(),
stats: RepoStats {
full_name: "farm/thing".to_owned(),
stargazers_count: 5_000,
forks_count: 2_500,
open_issues_count: 3,
created_at: "2026-10-07T00:00:00Z".to_owned(),
age_days: 11,
},
verdict: signalkit_integrity::ForkVerdict::Extreme,
fork_ratio_pct: 50.0,
};
let text = report.render();
assert!(text.contains("5000"));
assert!(text.contains("50.0%"));
assert!(text.contains("EXTREME"));
assert!(text.contains("heuristic"));
}
#[test]
fn marketplace_parse_handles_url_and_string_shapes() {
let json = r#"{"plugins": [
{"name": "a", "source": "owner/one"},
{"name": "b", "source": {"source": "git-subdir", "url": "https://github.com/owner/two.git", "path": "plugins/x", "ref": "main"}},
{"name": "c", "source": "./relative"},
{"name": "d", "source": {"source": "git-subdir", "repo": "owner/one"}},
{"name": "e", "source": {"source": "url", "url": "https://gitlab.com/nope/none.git"}}
]}"#;
let (repos, total) = parse_marketplace_repos(json);
assert_eq!(total, 5);
assert_eq!(repos, vec!["owner/one".to_owned(), "owner/two".to_owned()]);
}
#[test]
fn marketplace_parse_rejects_garbage() {
assert_eq!(parse_marketplace_repos("nope"), (Vec::<String>::new(), 0));
}
}