use crate::grammar::{parse, report_or_discover, Shape};
use crate::load::load_entries;
use crate::matching::{best_tier, q_match};
use candor_report::ReportEntry;
use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
fn denied_layer(fname: &str, effect: &str, rules: &[candor_classify::policy::PolicyRule]) -> Option<String> {
for rule in rules {
let denies = if rule.effects.is_empty() {
effect != candor_classify::policy::UNKNOWN
} else {
rule.effects.contains(effect)
};
let in_scope = rule
.scope
.as_deref()
.is_none_or(|s| candor_classify::policy::scope_matches(fname, s));
if denies && in_scope {
return Some(rule.scope.clone().unwrap_or_default());
}
}
None
}
fn denied_layer_evidenced(
e: &ReportEntry,
effect: &str,
rules: &[candor_classify::policy::PolicyRule],
reason_classes: Option<&BTreeSet<String>>,
) -> Option<String> {
let effs: Vec<&str> = e.inferred.iter().map(String::as_str).collect();
for rule in rules {
let in_scope = rule
.scope
.as_deref()
.is_none_or(|s| candor_classify::policy::scope_matches(&e.func, s));
if !in_scope {
continue;
}
if candor_classify::gate::rule_hits(rule, &effs, reason_classes, &e.net_class).hits.contains(&effect) {
return Some(rule.scope.clone().unwrap_or_default());
}
}
None
}
pub(crate) struct RemedyPlan<'a> {
func: &'a str,
effect: &'a str,
layer: String,
sites: BTreeSet<&'a str>,
denied_span: BTreeSet<&'a str>,
hoist_to: BTreeSet<&'a str>,
hoist_higher: BTreeSet<&'a str>,
clean_hoist: bool,
allow_edit: String,
}
impl RemedyPlan<'_> {
fn clean_hoist(&self) -> bool {
self.clean_hoist
}
fn sandwiched(&self) -> bool {
!self.clean_hoist && !self.hoist_to.is_empty()
}
fn dedup_key(&self) -> String {
format!(
"{}|{}|{:?}|{:?}",
self.effect,
self.layer,
self.sites.iter().collect::<Vec<_>>(),
self.hoist_to.iter().collect::<Vec<_>>()
)
}
fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"fn": self.func,
"effect": self.effect,
"layer": self.layer,
"cleanHoist": self.clean_hoist(),
"site": self.sites.iter().collect::<Vec<_>>(),
"deniedSpan": self.denied_span.iter().collect::<Vec<_>>(),
"hoistTo": self.hoist_to.iter().collect::<Vec<_>>(),
"hoistHigher": self.hoist_higher.iter().collect::<Vec<_>>(),
"policyAlternative": self.allow_edit,
})
}
fn render_text(&self, out: &mut String) {
use std::fmt::Write;
let layer_label = if self.layer.is_empty() { "global".to_string() } else { format!("`{}`", self.layer) };
let sitelist = if self.sites.is_empty() {
"(not a local source — a cross-crate or Unknown effect)".to_string()
} else {
self.sites.iter().map(|x| format!("`{x}`")).collect::<Vec<_>>().join(", ")
};
let _ = writeln!(out, "candor fix — hoist {} out of the {layer_label} boundary\n", self.effect);
let _ = writeln!(out, " The violation: `{}` performs {}, which the {layer_label} layer forbids.", self.func, self.effect);
let _ = writeln!(out, " Performed directly at: {sitelist}");
let span: Vec<_> = self.denied_span.iter().take(6).map(|x| format!("`{x}`")).collect();
let more = if self.denied_span.len() > 6 { ", …" } else { "" };
let _ = writeln!(out, " Forbidden across {} function(s) in the layer (they inherited it): {}{more}", self.denied_span.len(), span.join(", "));
let _ = writeln!(out);
if self.clean_hoist() {
let _ = writeln!(out, " THE FIX — hoist the effect to the boundary:");
let _ = writeln!(out, " · Perform {} at: {} (an allowed layer that already calls into the domain).", self.effect,
self.hoist_to.iter().map(|x| format!("`{x}`")).collect::<Vec<_>>().join(", "));
let _ = writeln!(out, " · Pass the result down as a parameter; the {} function(s) above then stay pure.", self.denied_span.len());
let _ = writeln!(out, " · Re-run the gate — the {layer_label} blast radius for {} should be empty.", self.effect);
if !self.hoist_higher.is_empty() {
let tops: Vec<_> = self.hoist_higher.iter().take(4).map(|x| format!("`{x}`")).collect();
let more = if self.hoist_higher.len() > 4 { ", …" } else { "" };
let _ = writeln!(out, " · TRADE-OFF — or hoist higher (up to {}{more}): the effect then originates further up,", tops.join(", "));
let _ = writeln!(out, " keeping the {} intervening allowed-layer function(s) pure too, at the cost of threading it through more signatures.", self.hoist_higher.len());
}
let _ = writeln!(out);
let _ = writeln!(out, " ALTERNATIVE — if the {layer_label} layer is MEANT to perform {}, it's a policy bug,", self.effect);
let _ = writeln!(out, " not a code one: relax the boundary with `{}`.", self.allow_edit);
} else {
if self.sandwiched() {
let _ = writeln!(out, " NO CLEAN HOIST — the nearest allowed layer ({}) is itself CALLED BY a {}-forbidding layer,",
self.hoist_to.iter().map(|x| format!("`{x}`")).collect::<Vec<_>>().join(", "), self.effect);
let _ = writeln!(out, " so hoisting {} there would leave that caller violating (a forbidden layer sandwiching an allowed one).", self.effect);
} else {
let _ = writeln!(out, " NO CLEAN HOIST — every caller up to the entry points is also in a {}-forbidding layer.", self.effect);
}
let _ = writeln!(out, " Three ways to fix it:");
let _ = writeln!(out, " (a) HOIST TO A NEW ENTRY POINT (recommended) — add a thin function ABOVE the {layer_label} layer that");
let _ = writeln!(out, " performs {} and passes the result DOWN as plain DATA; the {layer_label} functions take it as a", self.effect);
let _ = writeln!(out, " parameter and become PROVABLY pure (candor verifies no effect — clean under any policy). candor");
let _ = writeln!(out, " says \"no clean hoist\" only because no allowed caller EXISTS yet — you can add one; simplest fix.");
let _ = writeln!(out, " (b) INJECT via a fn/closure — give the {layer_label} layer a FUNCTION/CLOSURE parameter, supplied by an");
let _ = writeln!(out, " allowed adapter. This clears `deny {}`, but candor can't see THROUGH the injected function, so it", self.effect);
let _ = writeln!(out, " reads the {layer_label} as Unknown — a hole a `deny {} Unknown` policy would still flag; prefer (a) for", self.effect);
let _ = writeln!(out, " provable purity. Do NOT use a trait/interface port: candor resolves the dispatch back to its");
let _ = writeln!(out, " {}-performing impl, so the {layer_label} still trips the gate.", self.effect);
let _ = writeln!(out, " (c) If the {layer_label} layer legitimately needs {}, relax the boundary: `{}`.", self.effect, self.allow_edit);
}
}
}
fn compute_remedy<'a>(
by_name: &HashMap<&'a str, &'a ReportEntry>,
rev: &BTreeMap<&'a str, Vec<&'a str>>,
rules: &[candor_classify::policy::PolicyRule],
start: &'a ReportEntry,
effect: &'a str,
layer: String,
) -> RemedyPlan<'a> {
let mut sites: BTreeSet<&str> = BTreeSet::new();
let mut fseen: BTreeSet<&str> = BTreeSet::new();
let mut q: VecDeque<&str> = VecDeque::new();
q.push_back(start.func.as_str());
fseen.insert(start.func.as_str());
while let Some(cur) = q.pop_front() {
let Some(f) = by_name.get(cur) else { continue };
if f.direct.iter().any(|e| e == effect) {
sites.insert(cur);
}
for c in &f.calls {
if let Some(cf) = by_name.get(c.as_str())
&& cf.inferred.iter().any(|e| e == effect)
&& fseen.insert(c.as_str())
{
q.push_back(c.as_str());
}
}
}
let anchors: Vec<&str> = if sites.is_empty() { vec![start.func.as_str()] } else { sites.iter().copied().collect() };
let mut denied_span: BTreeSet<&str> = BTreeSet::new();
let mut hoist_to: BTreeSet<&str> = BTreeSet::new();
let mut up: VecDeque<&str> = VecDeque::new();
for &a in &anchors {
if denied_layer(a, effect, rules).is_some() {
denied_span.insert(a); }
up.push_back(a);
}
while let Some(cur) = up.pop_front() {
if let Some(cs) = rev.get(cur) {
for &caller in cs {
let Some(ce) = by_name.get(caller) else { continue };
if !ce.inferred.iter().any(|e| e == effect) {
continue;
}
if denied_layer(caller, effect, rules).is_some() {
if denied_span.insert(caller) {
up.push_back(caller); }
} else {
hoist_to.insert(caller); }
}
}
}
let mut hoist_higher: BTreeSet<&str> = BTreeSet::new();
let mut sandwiched = false;
let mut hq: VecDeque<&str> = hoist_to.iter().copied().collect();
let mut hseen: BTreeSet<&str> = hoist_to.iter().copied().collect();
while let Some(cur) = hq.pop_front() {
if let Some(cs) = rev.get(cur) {
for &caller in cs {
let Some(ce) = by_name.get(caller) else { continue };
if !ce.inferred.iter().any(|e| e == effect) {
continue;
}
if denied_layer(caller, effect, rules).is_some() {
sandwiched = true; } else if hseen.insert(caller) {
hoist_higher.insert(caller);
hq.push_back(caller);
}
}
}
}
let clean_hoist = !hoist_to.is_empty() && !sandwiched;
let allow_edit = if layer.is_empty() {
format!("allow {effect}")
} else {
format!("allow {effect} {layer}")
};
RemedyPlan { func: &start.func, effect, layer, sites, denied_span, hoist_to, hoist_higher, clean_hoist, allow_edit }
}
fn load_rules(policy_path: Option<String>) -> Result<candor_classify::policy::ParsedPolicy, i32> {
let policy_path = policy_path.or_else(|| std::env::var("CANDOR_POLICY").ok());
let Some(pp) = policy_path else {
eprintln!("candor fix: a policy is required (pass a policy file or set CANDOR_POLICY) — the fix is the refactor that restores the boundary the edit crossed.");
return Err(2);
};
crate::policy::load_policy_as_the_gate_does("fix", &pp)
}
fn unevaluated_json(unanswered: &[crate::gate::Unanswerable]) -> Vec<serde_json::Value> {
let mut seen: BTreeSet<&str> = BTreeSet::new();
unanswered
.iter()
.filter(|u| seen.insert(u.rule.as_str()))
.map(|u| serde_json::json!({ "rule": u.rule, "why": u.why }))
.collect()
}
fn reverse_graph(entries: &[ReportEntry]) -> BTreeMap<&str, Vec<&str>> {
let mut rev: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for e in entries {
for c in &e.calls {
rev.entry(c.as_str()).or_default().push(e.func.as_str());
}
}
rev
}
pub(crate) fn cmd_fix(args: &[String]) -> i32 {
let g = parse(args, Shape { verb_args: 2, sentinel: true, has_policy: true });
let (Some(target), Some(effect)) = (g.positional.first().cloned(), g.positional.get(1).cloned()) else {
eprintln!("usage: candor-query fix <fn> <Effect> [--report <locator>] [--policy <file>] [--json]");
return 2;
};
let (target, effect) = (&target, effect.as_str());
if candor_classify::cap_from_name(effect).is_none() && effect != "Unknown" {
eprintln!("candor: unknown effect `{effect}` (expected a candor effect name, e.g. Net/Fs/Db/Exec, or Unknown)");
return 2;
}
let Some(prefix) = report_or_discover(&g) else {
eprintln!("candor: no report found (no --report and no .candor/ discovered) — scan the crate first.");
return 2;
};
let prefix = &prefix;
let want_json = g.want_json;
let policy_path = g.policy.clone();
let parsed = match load_rules(policy_path) {
Ok(p) => p,
Err(c) => return c,
};
let rules = &parsed.rules;
let entries = load_entries(prefix);
if entries.is_empty() {
eprintln!("candor fix: no report for `{prefix}` — scan the crate first.");
return 2;
}
let by_name: HashMap<&str, &ReportEntry> = entries.iter().map(|e| (e.func.as_str(), e)).collect();
let comp = crate::completeness::report_completeness(prefix);
comp.warn_unreadable("fix");
let (so_what, tail) = (
"any remedy below is computed over a universe candor cannot fully see",
"A callee in one of those contributes no effect here, and a caller in one is invisible to the \
hoist. `gate --report` exits 2 over these bytes. Re-scan for a complete answer.",
);
if want_json {
comp.eprint_note(so_what, tail);
} else {
comp.print_note(so_what, tail);
}
let tier = best_tier(entries.iter().map(|e| e.func.as_str()), target);
let matches: Vec<&ReportEntry> = entries.iter().filter(|e| tier > 0 && q_match(&e.func, target, tier)).collect();
let Some(start) = matches
.iter()
.copied()
.find(|e| e.inferred.iter().any(|x| x == effect))
.or_else(|| matches.first().copied())
else {
eprintln!("candor fix: no function matching `{target}`.");
return 2;
};
if !start.inferred.iter().any(|e| e == effect) {
println!("candor fix: `{}` does not perform {effect} — nothing to hoist.", start.func);
return 0;
}
let sig = crate::gate::report_signature(&entries);
let refused: Vec<_> =
crate::gate::unanswerable_pairs(&parsed, &sig).into_iter().filter(|u| u.func == start.func).collect();
if !refused.is_empty() {
for u in &refused {
eprintln!("candor fix: `{}` — {}", u.rule, u.why);
}
if want_json {
let mut out = serde_json::json!({
"fn": start.func,
"effect": effect,
"unevaluated": unevaluated_json(&refused),
});
comp.write_json(&mut out);
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return 0;
}
println!(
"candor fix: `{}` performs {effect}, but `candor-query gate --report` CANNOT JUDGE it over \
this report ({} rule(s) above went unevaluated) — so there is no remedy to compute. Hoisting \
across a boundary nothing adjudicated would be a confident instruction resting on a guess: \
gate at scan time, or use the unnarrowed rule.",
start.func,
refused.len()
);
return 0;
}
let Some(layer) = denied_layer(&start.func, effect, rules) else {
println!(
"candor fix: `{}` performs {effect}, but no policy forbids it there — the boundary isn't crossed, nothing to fix.",
start.func
);
return 0;
};
let rev = reverse_graph(&entries);
let plan = compute_remedy(&by_name, &rev, rules, start, effect, layer);
if want_json {
let mut out = plan.to_json();
comp.write_json(&mut out);
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
let mut s = String::new();
plan.render_text(&mut s);
print!("{s}");
println!("\n (Advisory: candor names the shape, you write the code; the gate re-scan verifies the fix.)");
}
0
}
pub(crate) fn cmd_fix_gate(args: &[String]) -> i32 {
let g = parse(args, Shape { verb_args: 0, sentinel: true, has_policy: true });
let Some(prefix) = report_or_discover(&g) else {
eprintln!("candor: no report found (no --report and no .candor/ discovered) — scan the crate first.");
return 2;
};
let prefix = &prefix;
let want_json = g.want_json;
let policy_path = g.policy.clone();
let parsed = match load_rules(policy_path) {
Ok(p) => p,
Err(c) => return c,
};
let rules = &parsed.rules;
let entries = load_entries(prefix);
if entries.is_empty() {
eprintln!("candor fix-gate: no report for `{prefix}` — scan the crate first.");
return 2;
}
let by_name: HashMap<&str, &ReportEntry> = entries.iter().map(|e| (e.func.as_str(), e)).collect();
let rev = reverse_graph(&entries);
let mut sorted: Vec<&ReportEntry> = entries.iter().collect();
sorted.sort_by(|a, b| a.func.cmp(&b.func));
let sig = crate::gate::report_signature(&entries);
let reason_acc = &sig.reason_classes;
let unanswered = crate::gate::unanswerable_pairs(&parsed, &sig);
let comp = crate::completeness::report_completeness(prefix);
comp.warn_unreadable("fix-gate");
let mut plans: BTreeMap<String, RemedyPlan> = BTreeMap::new();
for e in sorted {
let mut effs: Vec<&String> = e.inferred.iter().collect();
effs.sort();
for effect in effs {
if let Some(layer) = denied_layer_evidenced(e, effect, rules, reason_acc.get(&e.func)) {
let plan = compute_remedy(&by_name, &rev, rules, e, effect, layer);
plans.entry(plan.dedup_key()).or_insert(plan);
}
}
}
if want_json {
let remedies: Vec<_> = plans.values().map(|p| p.to_json()).collect();
let mut out = serde_json::json!({ "remedies": remedies });
if unanswered.is_empty() && !comp.incomplete() {
out["ok"] = serde_json::json!(plans.is_empty());
}
if !unanswered.is_empty() {
out["unevaluated"] = serde_json::json!(unevaluated_json(&unanswered));
}
comp.write_json(&mut out);
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return fix_gate_exit(g.strict, !plans.is_empty(), !unanswered.is_empty(), comp.incomplete());
}
{
let mut seen: BTreeSet<&str> = BTreeSet::new();
for u in unanswered.iter().filter(|u| seen.insert(u.rule.as_str())) {
let n = unanswered.iter().filter(|o| o.rule == u.rule).count();
eprintln!(
"candor fix-gate: `{}` — {} No remedy is computed for `{}`{}: a hoist plan for a boundary \
the gate could not adjudicate is a confident instruction resting on a guess. \
`candor unverified` names them all.",
u.rule,
u.why,
u.func,
if n > 1 { format!(" or the {} other function(s) this rule cannot be evaluated on", n - 1) } else { String::new() }
);
}
}
comp.print_note(
"the remedies below are computed over a universe candor cannot fully see",
"A crossing in one of those is INVISIBLE here, and so is a caller a hoist would target. \
`gate --report` exits 2 over these bytes. Re-scan for a complete answer.",
);
if plans.is_empty() {
if unanswered.is_empty() && !comp.incomplete() {
println!("candor fix-gate: no deny/pure boundary crossings in this report ✓");
} else {
let mut why: Vec<String> = Vec::new();
if !unanswered.is_empty() {
why.push(format!("{} rule/function pair(s) went unevaluated (above)", unanswered.len()));
}
if comp.incomplete() {
why.push(format!("{} unit(s) were never analyzed (above)", comp.units()));
}
println!(
"candor fix-gate: no deny/pure boundary crossings CAN BE COMPUTED from this report — \
{}, and `candor-query gate --report` refuses over these bytes.",
why.join(", ")
);
}
return fix_gate_exit(g.strict, false, !unanswered.is_empty(), comp.incomplete());
}
let n = plans.len();
println!(
"candor fix — {n} boundary {} for this change:\n",
if n == 1 { "remedy" } else { "remedies" }
);
for (i, p) in plans.values().enumerate() {
if i > 0 {
println!(" ────────────────────────────────────────");
}
let mut s = String::new();
p.render_text(&mut s);
print!("{s}");
}
println!("\n (Advisory: candor names the shape, you write the code; the gate re-scan verifies each fix.)");
let rc = fix_gate_exit(g.strict, true, !unanswered.is_empty(), comp.incomplete());
if g.strict {
if rc == 2 && !unanswered.is_empty() {
println!(
" (--strict: {n} outstanding boundary crossing(s), AND {} rule/function pair(s) the gate \
could not evaluate → exit 2, matching `gate --report`)",
unanswered.len()
);
} else if rc == 2 {
println!(
" (--strict: {n} outstanding boundary crossing(s), AND {} unit(s) the scan never \
analyzed → exit 2, matching `gate --report`)",
comp.units()
);
} else {
println!(" (--strict: {n} outstanding boundary crossing(s) → exit 1)");
}
}
rc
}
fn fix_gate_exit(strict: bool, any_plans: bool, any_unanswered: bool, incomplete: bool) -> i32 {
match (strict, any_unanswered || incomplete, any_plans) {
(true, true, _) => 2,
(true, false, true) => 1,
_ => 0,
}
}