use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Area {
Crawl,
OnPage,
Content,
Links,
Structured,
AiAccess,
Performance,
Security,
Canonical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Critical,
High,
Medium,
Low,
Info,
}
#[derive(Debug, Clone)]
pub struct Rule {
pub id: &'static str,
pub area: Area,
pub severity: Severity,
pub effort: u8,
pub title: &'static str,
pub fix: &'static str,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub rule_id: String,
pub area: Area,
pub severity: Severity,
pub scope: String,
pub evidence: String,
pub fix: String,
#[serde(default = "fact_kind")]
pub kind: String,
#[serde(default)]
pub observed_at: u64,
#[serde(default = "engine_source")]
pub source: String,
}
fn fact_kind() -> String {
"fact".into()
}
fn engine_source() -> String {
"rule-engine".into()
}
pub fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn truth_kind(rule_id: &str) -> &'static str {
let bare = rule_id.strip_prefix("RULE-").unwrap_or(rule_id);
match bare.to_ascii_uppercase().as_str() {
"R18" | "R19" | "R20" | "R23" | "R24" | "R41" | "R54" | "R55" | "R58" | "R64" => {
"heuristic"
}
_ => "fact",
}
}
pub const RULE_SET_VERSION: &str = "2026.09b";
pub const RULES: &[Rule] = &[
Rule {
id: "R01",
area: Area::Crawl,
severity: Severity::High,
effort: 1,
title: "Broken link",
fix: "Restore the target or point the link at a live page.",
},
Rule {
id: "R02",
area: Area::Crawl,
severity: Severity::Critical,
effort: 2,
title: "Fetch failure",
fix: "Fix DNS, TLS, or server errors blocking the crawl.",
},
Rule {
id: "R03",
area: Area::Crawl,
severity: Severity::Medium,
effort: 2,
title: "Redirect chain",
fix: "Point the source at the final URL in one hop.",
},
Rule {
id: "R04",
area: Area::Crawl,
severity: Severity::Critical,
effort: 2,
title: "Redirect loop",
fix: "Break the cycle so the chain terminates.",
},
Rule {
id: "R05",
area: Area::Crawl,
severity: Severity::Medium,
effort: 1,
title: "No sitemap seed",
fix: "Publish /sitemap.xml so crawlers find every page.",
},
Rule {
id: "R06",
area: Area::Crawl,
severity: Severity::Medium,
effort: 1,
title: "No robots.txt",
fix: "Serve robots.txt so crawler rules are discoverable.",
},
Rule {
id: "R07",
area: Area::Crawl,
severity: Severity::Low,
effort: 1,
title: "Crawl capped",
fix: "Raise --max-pages for full coverage.",
},
Rule {
id: "R08",
area: Area::Crawl,
severity: Severity::Low,
effort: 2,
title: "Empty page body",
fix: "Ship renderable content or noindex the shell.",
},
Rule {
id: "R56",
area: Area::Crawl,
severity: Severity::High,
effort: 1,
title: "Soft 404",
fix: "Return 404 for missing pages instead of 200.",
},
Rule {
id: "R57",
area: Area::Crawl,
severity: Severity::Low,
effort: 1,
title: "Temporary host redirect",
fix: "Use 301 for www/apex and HTTP→HTTPS moves, not 302/307.",
},
Rule {
id: "R09",
area: Area::OnPage,
severity: Severity::High,
effort: 1,
title: "Missing title",
fix: "Write a unique title under 60 characters.",
},
Rule {
id: "R10",
area: Area::OnPage,
severity: Severity::Medium,
effort: 1,
title: "Title length",
fix: "Keep titles 30-60 characters so nothing truncates.",
},
Rule {
id: "R11",
area: Area::OnPage,
severity: Severity::High,
effort: 1,
title: "Missing meta description",
fix: "Write a 120-160 character summary.",
},
Rule {
id: "R12",
area: Area::OnPage,
severity: Severity::Medium,
effort: 1,
title: "Meta description length",
fix: "Trim or expand into the 120-160 window.",
},
Rule {
id: "R13",
area: Area::OnPage,
severity: Severity::High,
effort: 1,
title: "Missing H1",
fix: "Give the page one H1 stating its topic.",
},
Rule {
id: "R14",
area: Area::OnPage,
severity: Severity::Medium,
effort: 1,
title: "Multiple H1",
fix: "Keep one H1, demote the rest to H2.",
},
Rule {
id: "R15",
area: Area::OnPage,
severity: Severity::Medium,
effort: 1,
title: "Skipped heading level",
fix: "Nest headings in order without jumps.",
},
Rule {
id: "R16",
area: Area::OnPage,
severity: Severity::Low,
effort: 2,
title: "Images missing alt",
fix: "Describe every informative image.",
},
Rule {
id: "R17",
area: Area::OnPage,
severity: Severity::Low,
effort: 1,
title: "Missing favicon signal",
fix: "Link an icon so tabs and bookmarks render.",
},
Rule {
id: "R18",
area: Area::Content,
severity: Severity::Medium,
effort: 2,
title: "Thin page",
fix: "Expand past 300 words or merge into a parent.",
},
Rule {
id: "R19",
area: Area::Content,
severity: Severity::Medium,
effort: 2,
title: "AI slop markers",
fix: "Rewrite flagged boilerplate in plain words.",
},
Rule {
id: "R20",
area: Area::Content,
severity: Severity::Low,
effort: 2,
title: "Em-dash density",
fix: "Hold em-dashes under 2 per 500 words.",
},
Rule {
id: "R21",
area: Area::Content,
severity: Severity::Medium,
effort: 2,
title: "Duplicate titles",
fix: "Give every page a unique title.",
},
Rule {
id: "R22",
area: Area::Content,
severity: Severity::Medium,
effort: 2,
title: "Duplicate descriptions",
fix: "Give every page a unique summary.",
},
Rule {
id: "R23",
area: Area::Content,
severity: Severity::Medium,
effort: 2,
title: "Keyword cannibalization",
fix: "One winner per stem; merge or re-target the rest.",
},
Rule {
id: "R24",
area: Area::Content,
severity: Severity::Low,
effort: 2,
title: "No direct answer opening",
fix: "Open with the answer before the background.",
},
Rule {
id: "R54",
area: Area::Content,
severity: Severity::Medium,
effort: 1,
title: "Templated metadata",
fix: "Write descriptions that describe the page, not echo the title with a stock CTA.",
},
Rule {
id: "R55",
area: Area::Content,
severity: Severity::Medium,
effort: 2,
title: "Uncited claims",
fix: "Attach a source, study, or link within sight of every statistic.",
},
Rule {
id: "R25",
area: Area::Links,
severity: Severity::Medium,
effort: 2,
title: "Orphan page",
fix: "Link inward from a related page.",
},
Rule {
id: "R26",
area: Area::Links,
severity: Severity::Low,
effort: 1,
title: "No outbound links",
fix: "Cite at least one source or next step.",
},
Rule {
id: "R27",
area: Area::Links,
severity: Severity::Medium,
effort: 1,
title: "HTTP outbound link",
fix: "Point outbound links at HTTPS targets.",
},
Rule {
id: "R28",
area: Area::Links,
severity: Severity::Low,
effort: 1,
title: "Empty anchor text",
fix: "Write anchors that describe the target.",
},
Rule {
id: "R29",
area: Area::Links,
severity: Severity::Low,
effort: 2,
title: "Nofollow-heavy page",
fix: "Let internal links pass value; save nofollow for untrusted.",
},
Rule {
id: "R30",
area: Area::Links,
severity: Severity::Low,
effort: 1,
title: "Fragment-only links",
fix: "Point navigation at real pages, not anchors.",
},
Rule {
id: "R31",
area: Area::Structured,
severity: Severity::Medium,
effort: 2,
title: "No JSON-LD",
fix: "Add Schema.org markup matching the page type.",
},
Rule {
id: "R32",
area: Area::Structured,
severity: Severity::Medium,
effort: 1,
title: "Missing rich-result props",
fix: "Fill the properties search requires for rich results.",
},
Rule {
id: "R33",
area: Area::Structured,
severity: Severity::High,
effort: 1,
title: "Invalid JSON-LD",
fix: "Fix the syntax so parsers accept the block.",
},
Rule {
id: "R34",
area: Area::Structured,
severity: Severity::Medium,
effort: 1,
title: "Deprecated schema type",
fix: "Migrate to the current type.",
},
Rule {
id: "R35",
area: Area::Structured,
severity: Severity::Low,
effort: 1,
title: "Missing Open Graph tags",
fix: "Add og:title, description, and image.",
},
Rule {
id: "R36",
area: Area::Canonical,
severity: Severity::Low,
effort: 1,
title: "Missing canonical",
fix: "Point every page at its canonical URL.",
},
Rule {
id: "R58",
area: Area::OnPage,
severity: Severity::Medium,
effort: 2,
title: "Broken hreflang cluster",
fix: "Make every hreflang pair reciprocal and never point at a noindexed target.",
},
Rule {
id: "R59",
area: Area::Canonical,
severity: Severity::High,
effort: 1,
title: "Meta robots conflict",
fix: "One robots directive per page: remove the contradicting noindex or nofollow.",
},
Rule {
id: "R60",
area: Area::Canonical,
severity: Severity::High,
effort: 1,
title: "X-Robots-Tag conflict",
fix: "Align the X-Robots-Tag header with the meta robots tag.",
},
Rule {
id: "R61",
area: Area::Canonical,
severity: Severity::High,
effort: 1,
title: "Canonical target invalid",
fix: "Point canonical at a 200 HTTPS URL, not a redirect or error page.",
},
Rule {
id: "R62",
area: Area::Canonical,
severity: Severity::Medium,
effort: 1,
title: "Canonical target noindex",
fix: "Never canonicalize to a noindexed page; pick an indexable target.",
},
Rule {
id: "R63",
area: Area::Crawl,
severity: Severity::Medium,
effort: 1,
title: "Sitemap URL noncanonical",
fix: "List only canonical 200 URLs in the sitemap.",
},
Rule {
id: "R64",
area: Area::Crawl,
severity: Severity::Medium,
effort: 2,
title: "Render/source content divergence",
fix: "Server-render primary content; JS-only shells lose indexable words.",
},
Rule {
id: "R37",
area: Area::AiAccess,
severity: Severity::Info,
effort: 1,
title: "No llms.txt (informational)",
fix: "Optional plumbing for ChatGPT/Perplexity/Claude; Google Search ignores the file.",
},
Rule {
id: "R38",
area: Area::AiAccess,
severity: Severity::Low,
effort: 1,
title: "AI crawlers unnamed",
fix: "Name AI crawlers explicitly in robots.txt.",
},
Rule {
id: "R39",
area: Area::AiAccess,
severity: Severity::High,
effort: 1,
title: "AI training blocked",
fix: "Allow the crawlers whose citations are wanted.",
},
Rule {
id: "R40",
area: Area::AiAccess,
severity: Severity::Low,
effort: 1,
title: "Sitemap not advertised",
fix: "Add the Sitemap line to robots.txt.",
},
Rule {
id: "R41",
area: Area::AiAccess,
severity: Severity::Low,
effort: 2,
title: "Low citation density",
fix: "Raise facts per paragraph with numbers and definitions.",
},
Rule {
id: "R42",
area: Area::Performance,
severity: Severity::Medium,
effort: 2,
title: "Slow page over 800ms",
fix: "Cut server time and payload below the budget.",
},
Rule {
id: "R43",
area: Area::Performance,
severity: Severity::Low,
effort: 2,
title: "Oversize page body",
fix: "Trim pages past 2MB or paginate.",
},
Rule {
id: "R44",
area: Area::Performance,
severity: Severity::Low,
effort: 1,
title: "No compression signal",
fix: "Serve gzip or brotli with a content-encoding header.",
},
Rule {
id: "R45",
area: Area::Performance,
severity: Severity::Low,
effort: 2,
title: "Render-blocking weight",
fix: "Defer non-critical scripts and styles.",
},
Rule {
id: "R46",
area: Area::Performance,
severity: Severity::Low,
effort: 1,
title: "Images without dimensions",
fix: "Set width and height to stop layout shift.",
},
Rule {
id: "R47",
area: Area::Security,
severity: Severity::High,
effort: 1,
title: "Serves over HTTP",
fix: "Redirect everything to HTTPS with HSTS.",
},
Rule {
id: "R48",
area: Area::Security,
severity: Severity::Medium,
effort: 1,
title: "Mixed content",
fix: "Upgrade HTTP subresources to HTTPS.",
},
Rule {
id: "R49",
area: Area::Canonical,
severity: Severity::Medium,
effort: 1,
title: "Canonical variant duplicates",
fix: "One URL per page: pick slash policy and enforce it.",
},
Rule {
id: "R50",
area: Area::Canonical,
severity: Severity::Low,
effort: 1,
title: "Tracking-param URLs indexed",
fix: "Strip marketing params from canonicals and sitemaps.",
},
Rule {
id: "R51",
area: Area::Performance,
severity: Severity::Medium,
effort: 2,
title: "Slow LCP over 2500ms",
fix: "Cut render-blocking weight above the fold; lab budget is 2.5s.",
},
Rule {
id: "R52",
area: Area::Performance,
severity: Severity::Medium,
effort: 2,
title: "Layout shift over 0.100",
fix: "Set image and embed dimensions; hold CLS under 0.10.",
},
Rule {
id: "R53",
area: Area::Performance,
severity: Severity::Low,
effort: 1,
title: "No Chrome field data",
fix: "Lab numbers only until the URL earns CrUX traffic; treat them as directional.",
},
];
pub fn rule(id: &str) -> Option<&'static Rule> {
let bare = id.strip_prefix("RULE-").unwrap_or(id);
RULES.iter().find(|r| r.id.eq_ignore_ascii_case(bare))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Gate {
Blocking,
Advisory,
}
pub fn gate(id: &str) -> Gate {
let bare = id.strip_prefix("RULE-").unwrap_or(id);
match bare.to_ascii_uppercase().as_str() {
"R01" | "R02" | "R03" | "R04" | "R05" | "R06" | "R07" | "R08" | "R09" | "R11" | "R13"
| "R16" | "R21" | "R22" | "R26" | "R31" | "R33" | "R36" | "R43" | "R47" | "R56" | "R57"
| "R59" | "R60" | "R61" => Gate::Blocking,
_ => Gate::Advisory,
}
}
pub fn blocking_findings(findings: &[Finding]) -> Vec<&Finding> {
findings
.iter()
.filter(|f| effective_gate(&f.rule_id, &f.scope) == Gate::Blocking)
.collect()
}
pub fn effective_gate(rule_id: &str, scope: &str) -> Gate {
if gate(rule_id) != Gate::Blocking {
return Gate::Advisory;
}
let lower = scope.to_ascii_lowercase();
let is_md = lower.ends_with(".md") || lower.ends_with(".mdx") || lower.ends_with(".markdown");
if is_md {
let bare = rule_id.strip_prefix("RULE-").unwrap_or(rule_id);
if matches!(
bare.to_ascii_uppercase().as_str(),
"R11" | "R26" | "R31" | "R35" | "R36"
) {
return Gate::Advisory;
}
}
Gate::Blocking
}
pub fn explain(id: &str) -> Option<String> {
let r = rule(id)?;
let gate_label = match gate(r.id) {
Gate::Blocking => "blocking (fails CI gates)",
Gate::Advisory => "advisory (warning only)",
};
Some(format!(
"{} | {} | {:?} | {} | effort {} ({})\n title: {}\n fix: {}",
r.id,
label(&r.area),
r.severity,
gate_label,
r.effort,
match r.effort {
1 => "hours",
2 => "about a day",
3 => "several days",
_ => "a project",
},
r.title,
r.fix
))
}
fn deduct(sev: Severity) -> f64 {
match sev {
Severity::Critical => 25.0,
Severity::High => 12.0,
Severity::Medium => 6.0,
Severity::Low => 2.0,
Severity::Info => 0.0,
}
}
pub const AREA_WEIGHTS: &[(Area, f64)] = &[
(Area::Crawl, 15.0),
(Area::OnPage, 15.0),
(Area::Content, 15.0),
(Area::Links, 10.0),
(Area::Structured, 10.0),
(Area::AiAccess, 10.0),
(Area::Performance, 10.0),
(Area::Security, 10.0),
(Area::Canonical, 5.0),
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AreaScore {
pub area: Area,
pub score: u32,
pub findings: usize,
}
pub fn score_areas(findings: &[Finding], totals: &HashMap<Area, usize>) -> Vec<AreaScore> {
let mut by_area: HashMap<Area, Vec<&Finding>> = HashMap::new();
for f in findings {
by_area.entry(f.area).or_default().push(f);
}
let mut out = Vec::new();
for (area, _) in AREA_WEIGHTS {
let empty: Vec<&Finding> = Vec::new();
let list = by_area.get(area).unwrap_or(&empty);
let total = totals.get(area).copied().unwrap_or(1).max(1) as f64;
let mut hits: HashMap<&str, std::collections::HashSet<&str>> = HashMap::new();
for f in list {
hits.entry(f.rule_id.as_str())
.or_default()
.insert(f.scope.as_str());
}
let mut penalty = 0.0;
for (rule_id, scopes) in &hits {
let sev = list
.iter()
.find(|f| &f.rule_id == rule_id)
.map(|f| f.severity)
.unwrap_or(Severity::Low);
let reach = (scopes.len() as f64 / total).min(1.0);
penalty += deduct(sev) * (0.5 + 0.5 * reach);
}
out.push(AreaScore {
area: *area,
score: (100.0 - penalty).max(0.0).round() as u32,
findings: list.len(),
});
}
out
}
pub fn overall(areas: &[AreaScore]) -> u32 {
let mut total = 0.0;
let mut weight = 0.0;
for a in areas {
let w = AREA_WEIGHTS
.iter()
.find(|(ar, _)| ar == &a.area)
.map(|(_, w)| w)
.copied()
.unwrap_or(0.0);
total += a.score as f64 * w;
weight += w;
}
if weight == 0.0 {
return 100;
}
(total / weight).round() as u32
}
pub fn actions_for(findings: &[Finding]) -> Vec<crate::actions::Action> {
let mut by_rule: HashMap<&str, Vec<&Finding>> = HashMap::new();
for f in findings {
by_rule.entry(f.rule_id.as_str()).or_default().push(f);
}
let mut ids: Vec<&str> = by_rule.keys().copied().collect();
ids.sort();
let mut out = Vec::new();
for id in ids {
let list = &by_rule[id];
let first = list[0];
let priority = match first.severity {
Severity::Critical | Severity::High => 1,
Severity::Medium => 2,
Severity::Low | Severity::Info => 3,
};
let effort = rule(id).map(|r| r.effort).unwrap_or(2);
let title = rule(id).map(|r| r.title).unwrap_or("Fix flagged issue");
let mut ev: Vec<String> = list.iter().take(3).map(|f| f.scope.clone()).collect();
if list.len() > 3 {
ev.push(format!("+{} more", list.len() - 3));
}
out.push(crate::actions::Action::new(
&format!("RULE-{}", id),
priority,
effort,
&format!("{} ({} hits)", title, list.len()),
ev.join(", "),
));
}
crate::actions::rank(out)
}
pub fn to_csv(findings: &[Finding]) -> String {
let mut s = String::from("rule,area,severity,kind,observed_at,source,scope,evidence\n");
for f in findings {
let cell = |v: &str| format!("\"{}\"", v.replace('"', "\"\""));
s.push_str(&format!(
"{},{},{},{},{},{},{},{}\n",
f.rule_id,
label(&f.area),
format!("{:?}", f.severity).to_lowercase(),
f.kind,
f.observed_at,
f.source,
cell(&f.scope),
cell(&f.evidence)
));
}
s
}
pub fn label(area: &Area) -> &'static str {
match area {
Area::Crawl => "crawl",
Area::OnPage => "on-page",
Area::Content => "content",
Area::Links => "links",
Area::Structured => "structured",
Area::AiAccess => "ai-access",
Area::Performance => "performance",
Area::Security => "security",
Area::Canonical => "canonical",
}
}
fn track_param(k: &str) -> bool {
let k = k.to_lowercase();
k == "amp"
|| k.starts_with("utm_")
|| k == "fbclid"
|| k == "gclid"
|| k == "msclkid"
|| k == "yclid"
}
fn has_tracking(url: &str) -> bool {
url::Url::parse(url)
.map(|u| u.query_pairs().any(|(k, _)| track_param(&k)))
.unwrap_or(false)
}
pub fn probe_finding(rule_id: &str, scope: String, evidence: String) -> Option<Finding> {
let r = rule(rule_id)?;
let mut f = mk(r.id, scope, evidence);
f.source = "live-crawl".into();
Some(f)
}
fn mk(rule_id: &'static str, scope: String, evidence: String) -> Finding {
let r = rule(rule_id).expect("rule id");
Finding {
rule_id: rule_id.to_string(),
area: r.area,
severity: r.severity,
scope,
evidence,
fix: r.fix.to_string(),
kind: truth_kind(rule_id).to_string(),
observed_at: now_ms(),
source: engine_source(),
}
}
pub fn check_crawl(rep: &crate::crawl::CrawlReport) -> Vec<Finding> {
use crate::crawl::{MAX_BODY_BYTES, SLOW_PAGE_MS};
let mut out = Vec::new();
for p in &rep.pages {
if p.status == 0 {
if p.hops.is_empty() {
out.push(mk("R02", p.url.clone(), "fetch failed".into()));
} else {
out.push(mk(
"R03",
p.url.clone(),
format!("chain exhausted after {} hops", p.hops.len()),
));
}
} else if p.status >= 400 {
out.push(mk("R01", p.url.clone(), format!("HTTP {}", p.status)));
}
if p.status == 200 && p.bytes == 0 {
out.push(mk("R08", p.url.clone(), "empty body".into()));
}
if p.status == 200 && p.elapsed_ms > SLOW_PAGE_MS {
out.push(mk("R42", p.url.clone(), format!("{}ms", p.elapsed_ms)));
}
if p.bytes > MAX_BODY_BYTES {
out.push(mk("R43", p.url.clone(), format!("{} bytes", p.bytes)));
}
if p.status == 200
&& p.encoding
.as_deref()
.map(|e| !e.contains("gzip") && !e.contains("br") && !e.contains("zstd"))
.unwrap_or(true)
{
out.push(mk("R44", p.url.clone(), "no content-encoding".into()));
}
if p.url.starts_with("http://") {
out.push(mk("R47", p.url.clone(), "plain HTTP".into()));
}
if p.hops.len() >= 5 || p.hops.iter().any(|(_, u)| u == &p.final_url) {
out.push(mk("R04", p.url.clone(), format!("{} hops", p.hops.len())));
}
if has_tracking(&p.final_url) {
out.push(mk("R50", p.url.clone(), "tracking params served".into()));
}
if p.final_url.trim_end_matches('/') != p.url.trim_end_matches('/')
&& crate::crawl::canonicalize(&p.final_url) == p.url
{
out.push(mk(
"R49",
p.url.clone(),
format!("serves variant {}", p.final_url),
));
}
}
if let Some(v) = &rep.vitals {
if let Some(lcp) = v.lcp_ms {
if lcp > crate::vitals::LCP_MS {
out.push(mk("R51", rep.start_url.clone(), format!("LCP {}ms", lcp)));
}
}
if let Some(cls) = v.cls_milli {
if cls > crate::vitals::CLS_MILLI {
out.push(mk(
"R52",
rep.start_url.clone(),
format!("CLS {}", crate::vitals::cls_display(cls)),
));
}
}
if !v.field && (v.lcp_ms.is_some() || v.cls_milli.is_some() || v.inp_ms.is_some()) {
let inp = v
.inp_ms
.map(|x| format!(", INP {}ms", x))
.unwrap_or_default();
out.push(mk(
"R53",
rep.start_url.clone(),
format!("lab only, no field data{}", inp),
));
}
}
for o in &rep.orphans {
out.push(mk("R25", o.clone(), "0 inbound links".into()));
}
for p in &rep.pages {
if p.status != 0 && p.hops.len() >= 2 {
out.push(mk(
"R03",
p.url.clone(),
format!("{} hops -> {}", p.hops.len(), p.final_url),
));
}
}
if !rep.seeded_from_sitemap {
out.push(mk("R05", rep.start_url.clone(), "no /sitemap.xml".into()));
}
if !rep.robots_honored {
out.push(mk("R06", rep.start_url.clone(), "no robots.txt".into()));
}
if rep.capped {
out.push(mk(
"R07",
rep.start_url.clone(),
format!("capped at {} pages", rep.pages_crawled),
));
}
for f in &mut out {
f.source = "live-crawl".into();
}
out
}
pub fn check_audit(rep: &crate::audit::DirectoryAuditReport) -> Vec<Finding> {
let mut out = Vec::new();
for r in &rep.reports {
let scope = r.file_path.clone();
match &r.title {
None => out.push(mk("R09", scope.clone(), "no title".into())),
Some(t) => {
if r.title_len < 30 || r.title_len > 60 {
out.push(mk(
"R10",
scope.clone(),
format!("{} chars: {}", r.title_len, t),
));
}
}
}
match &r.description {
None => out.push(mk("R11", scope.clone(), "no description".into())),
Some(_) => {
if r.description_len < 120 || r.description_len > 160 {
out.push(mk(
"R12",
scope.clone(),
format!("{} chars", r.description_len),
));
}
}
}
if r.h1_count == 0 {
out.push(mk("R13", scope.clone(), "no H1".into()));
} else if r.h1_count > 1 {
out.push(mk("R14", scope.clone(), format!("{} H1", r.h1_count)));
}
if !r.heading_skipped_levels.is_empty() {
out.push(mk(
"R15",
scope.clone(),
r.heading_skipped_levels.join(", "),
));
}
if r.images_missing_alt > 0 {
out.push(mk(
"R16",
scope.clone(),
format!("{} missing alt", r.images_missing_alt),
));
}
if r.word_count < 300 {
out.push(mk("R18", scope.clone(), format!("{} words", r.word_count)));
}
let slop_density = r.em_dash_count >= 2
&& r.word_count > 0
&& (r.em_dash_count as f64 / r.word_count as f64) * 500.0 > 2.0;
if slop_density || r.ai_slop_words_found.len() >= 3 {
out.push(mk(
"R19",
scope.clone(),
format!(
"{} em-dashes, {}",
r.em_dash_count,
r.ai_slop_words_found.join(", ")
),
));
}
if r.word_count > 0 && (r.em_dash_count as f64) * 500.0 / (r.word_count.max(1) as f64) > 2.0
{
out.push(mk(
"R20",
scope.clone(),
format!("{} em-dashes", r.em_dash_count),
));
}
if r.internal_links == 0 && r.external_links == 0 {
out.push(mk("R26", scope.clone(), "no outbound links".into()));
}
if !r.schema_found {
out.push(mk("R31", scope.clone(), "no JSON-LD".into()));
} else if !r.schema_json_valid {
out.push(mk(
"R33",
scope.clone(),
"ld+json block fails to parse".into(),
));
}
if !r.canonical_found {
out.push(mk("R36", scope.clone(), "no canonical".into()));
}
if !r.og_tags_found {
out.push(mk("R35", scope.clone(), "no Open Graph tags".into()));
}
if r.geo_opening_words < crate::audit::GEO_MIN_WORDS {
out.push(mk(
"R24",
scope.clone(),
format!("opening {} words", r.geo_opening_words),
));
} else if r.geo_opening_words > crate::audit::GEO_MAX_WORDS {
out.push(mk(
"R41",
scope.clone(),
format!("opening {} words", r.geo_opening_words),
));
}
if r.uncited_claims > 0 {
out.push(mk(
"R55",
scope.clone(),
format!("{} statistics without a nearby source", r.uncited_claims),
));
}
if let (Some(t), Some(d)) = (&r.title, &r.description) {
let tl = t.to_lowercase();
let dl = d.to_lowercase();
let mut signals = Vec::new();
if tl.len() >= 10 && dl == tl {
signals.push("description duplicates title");
} else if tl.len() >= 10 && dl.starts_with(&tl) {
signals.push("description opens with title");
}
for cta in [
"learn more",
"contact us",
"read more",
"click here",
"shop now",
"sign up",
] {
if dl.ends_with(cta) {
signals.push("stock CTA close");
break;
}
}
if !signals.is_empty() {
out.push(mk("R54", scope.clone(), signals.join("; ")));
}
}
if r.js_shell {
let mut f = mk("R08", scope.clone(), "JS-only shell: thin readable text with heavy script payload (ponytail: warn only, render with --features js)".into());
f.severity = Severity::Low;
out.push(f);
}
if !r.schema_missing_required.is_empty() {
out.push(mk(
"R32",
scope.clone(),
r.schema_missing_required.join("; "),
));
}
}
for (title, files) in &rep.duplicate_titles {
out.push(mk(
"R21",
files.first().cloned().unwrap_or_default(),
format!("shared title: {}", title),
));
}
let mut by_desc: HashMap<&str, Vec<&str>> = HashMap::new();
for r in &rep.reports {
if let Some(d) = &r.description {
by_desc
.entry(d.as_str())
.or_default()
.push(r.file_path.as_str());
}
}
for (desc, files) in &by_desc {
if files.len() > 1 {
out.push(mk(
"R22",
files[0].to_string(),
format!("shared description: {}", desc),
));
}
}
for item in &rep.keyword_cannibalization {
out.push(mk(
"R23",
item.colliding_files.first().cloned().unwrap_or_default(),
format!("stem: {}", item.keyword_stem),
));
}
for f in &rep.orphan_pages {
out.push(mk("R25", f.clone(), "0 inbound links".into()));
}
let by_path: std::collections::HashMap<String, &crate::audit::AuditReport> = rep
.reports
.iter()
.map(|r| {
(
std::path::Path::new(&r.file_path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
r,
)
})
.collect();
let mut seen_cluster: Vec<(String, String, String)> = Vec::new();
for r in &rep.reports {
for (lang, href) in &r.hreflang_alternates {
let bare = href
.split(['/', '\\'])
.next_back()
.unwrap_or("")
.to_string();
let target = match by_path.get(&bare) {
Some(t) => t,
None => continue, };
let is_self = target.file_path == r.file_path;
let has_index = !target.noindex;
let governs = has_index
&& (is_self
|| target.hreflang_alternates.iter().any(|(l, h)| {
l == lang && {
let other = h.split(['/', '\\']).next_back().unwrap_or("");
other
== std::path::Path::new(&r.file_path)
.file_name()
.unwrap_or_default()
}
}));
if !governs {
let why = if !has_index && !is_self {
"points at a noindexed target"
} else if is_self {
"points at itself"
} else {
"alternate is not reciprocated"
};
let dup = seen_cluster.contains(&(
r.file_path.clone(),
target.file_path.clone(),
lang.clone(),
));
if !dup {
seen_cluster.push((
r.file_path.clone(),
target.file_path.clone(),
lang.clone(),
));
out.push(mk(
"R58",
r.file_path.clone(),
format!("{} -> {} ({}) {why}", lang, target.file_path, href),
));
}
}
}
}
for f in &mut out {
f.source = "local-audit".into();
}
out
}