use crate::load::glob_reports;
pub(crate) struct Unreadable {
pub(crate) path: String,
pub(crate) key_present: bool,
}
#[derive(serde::Serialize)]
pub(crate) struct CompletenessFields {
pub(crate) incomplete: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(crate) unanalyzed: Vec<candor_report::UnanalyzedUnit>,
#[serde(rename = "judgedNothing", skip_serializing_if = "Vec::is_empty")]
pub(crate) judged_nothing: Vec<String>,
#[serde(rename = "noManifest", skip_serializing_if = "Vec::is_empty")]
pub(crate) no_manifest: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(crate) unread: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub(crate) refused: Vec<String>,
}
#[derive(serde::Serialize)]
pub(crate) struct BaselineCompletenessFields {
#[serde(rename = "baselineIncomplete")]
pub(crate) incomplete: bool,
#[serde(rename = "baselineUnanalyzed", skip_serializing_if = "Vec::is_empty")]
pub(crate) unanalyzed: Vec<candor_report::UnanalyzedUnit>,
#[serde(rename = "baselineJudgedNothing", skip_serializing_if = "Vec::is_empty")]
pub(crate) judged_nothing: Vec<String>,
#[serde(rename = "baselineNoManifest", skip_serializing_if = "Vec::is_empty")]
pub(crate) no_manifest: Vec<String>,
#[serde(rename = "baselineUnread", skip_serializing_if = "Vec::is_empty")]
pub(crate) unread: Vec<String>,
#[serde(rename = "baselineRefused", skip_serializing_if = "Vec::is_empty")]
pub(crate) refused: Vec<String>,
}
pub(crate) struct ReportCompleteness {
pub(crate) unanalyzed: Vec<candor_report::UnanalyzedUnit>,
pub(crate) unreadable: Vec<Unreadable>,
pub(crate) judged_nothing: Vec<String>,
pub(crate) no_manifest: Vec<String>,
pub(crate) out_of_scope: Vec<candor_report::OutOfScopeFinding>,
pub(crate) unread: Vec<String>,
pub(crate) unread_armed: bool,
pub(crate) scanned_under_facts: Vec<(bool, Option<Vec<String>>, String)>,
pub(crate) unasked_rules: Vec<String>,
pub(crate) unasked_rules_predates_033: bool,
pub(crate) refused: Vec<candor_report::RefusalMarker>,
}
pub(crate) fn arm_unread(mut c: ReportCompleteness, p: &candor_classify::policy::ParsedPolicy) -> ReportCompleteness {
if p.rules.is_empty() {
c.unread.clear();
}
c.unread_armed = !c.unread.is_empty();
c
}
pub(crate) fn arm_unasked_rules(
mut c: ReportCompleteness,
p: &candor_classify::policy::ParsedPolicy,
) -> ReportCompleteness {
let own = candor_classify::policy::canonical_deny_set(&p.rules);
let mut missing: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut missing_from_current_spec = false;
if !own.is_empty() {
for (any_peeked, scanned_under, spec) in &c.scanned_under_facts {
if !*any_peeked {
continue;
}
let theirs: std::collections::BTreeSet<&str> =
scanned_under.as_deref().unwrap_or(&[]).iter().map(String::as_str).collect();
let predates = candor_report::spec_predates(spec, "0.33"); for r in &own {
if !theirs.contains(r.as_str()) {
missing.insert(r.clone());
if !predates {
missing_from_current_spec = true;
}
}
}
}
}
c.unasked_rules = missing.into_iter().collect();
c.unasked_rules_predates_033 = !c.unasked_rules.is_empty() && !missing_from_current_spec;
c
}
impl ReportCompleteness {
pub(crate) fn incomplete(&self) -> bool {
!self.unanalyzed.is_empty()
|| !self.unreadable.is_empty()
|| !self.out_of_scope.is_empty()
|| self.unread_armed
|| !self.unasked_rules.is_empty()
|| !self.refused.is_empty()
}
pub(crate) fn must_hedge(&self) -> bool {
self.incomplete()
|| !self.judged_nothing.is_empty()
|| !self.no_manifest.is_empty()
|| !self.unread.is_empty()
}
pub(crate) fn units(&self) -> usize {
self.unanalyzed.len() + self.unreadable.len()
}
pub(crate) fn gate_line(&self) -> &'static str {
if self.incomplete() {
"`gate --report` exits 2 over these bytes."
} else if !self.unread.is_empty() {
"`gate --report` exits 2 over these bytes under any policy it can evaluate (they are all \
`deny`/`pure`), and this verb holds none — so NOTHING DOWNSTREAM IS FAILING CLOSED ON IT \
HERE and this note is the whole of the warning."
} else if self.judged_nothing.is_empty() {
"NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU — `gate --report` exits 0 over a report carrying \
no `analyzed` manifest (⟨0.24⟩: a disclosure, not an exit code), so this note is the whole \
of the warning."
} else {
"NOTHING DOWNSTREAM WILL CATCH THIS FOR YOU — `gate --report` exits 0 over a judged-nothing \
report (⟨0.24⟩: a disclosure, not an exit code), so this note is the whole of the warning."
}
}
pub(crate) fn absorb(&mut self, other: ReportCompleteness) {
self.unanalyzed.extend(other.unanalyzed);
self.unreadable.extend(other.unreadable);
self.judged_nothing.extend(other.judged_nothing);
self.no_manifest.extend(other.no_manifest);
self.unread.extend(other.unread);
self.unread_armed |= other.unread_armed;
self.scanned_under_facts.extend(other.scanned_under_facts);
self.unasked_rules_predates_033 = match
(!self.unasked_rules.is_empty(), !other.unasked_rules.is_empty())
{
(false, false) => false,
(true, false) => self.unasked_rules_predates_033,
(false, true) => other.unasked_rules_predates_033,
(true, true) => self.unasked_rules_predates_033 && other.unasked_rules_predates_033,
};
self.unasked_rules.extend(other.unasked_rules);
self.refused.extend(other.refused);
}
pub(crate) fn warn_unreadable(&self, verb: &str) {
for u in &self.unreadable {
let p = &u.path;
if u.key_present {
eprintln!(
"candor {verb}: report {p} — the `unanalyzed` key is PRESENT but is not a list of \
`{{ path, reason }}` (SPEC §2). A key that cannot be READ is corrupt input, never \
its empty value, and here the empty value is what licenses `ok` — so this answer \
is reported INCOMPLETE. Fix the key, or re-run the scan that wrote it."
);
} else {
eprintln!(
"candor {verb}: report {p} — could not be READ at all, so whether it declares \
unanalyzed source is unknown. `candor-query gate --report` refuses over this \
file, so this answer is reported INCOMPLETE rather than clean. Re-run the scan."
);
}
}
}
pub(crate) fn write_json(&self, out: &mut serde_json::Value) {
let Some(f) = self.fields() else { return };
let serde_json::Value::Object(f) = serde_json::to_value(f).unwrap() else { return };
for (k, v) in f {
out[k] = v;
}
}
pub(crate) fn fields(&self) -> Option<CompletenessFields> {
if !self.must_hedge() {
return None;
}
Some(CompletenessFields {
incomplete: true,
unanalyzed: self.unanalyzed.clone(),
judged_nothing: self.judged_nothing.clone(),
no_manifest: self.no_manifest.clone(),
unread: if self.unread_armed { self.unread.clone() } else { Vec::new() },
refused: self.refused.iter().map(|m| m.reason.clone()).collect(),
})
}
pub(crate) fn baseline_fields(&self) -> Option<BaselineCompletenessFields> {
if !self.must_hedge() {
return None;
}
Some(BaselineCompletenessFields {
incomplete: true,
unanalyzed: self.unanalyzed.clone(),
judged_nothing: self.judged_nothing.clone(),
no_manifest: self.no_manifest.clone(),
unread: if self.unread_armed { self.unread.clone() } else { Vec::new() },
refused: self.refused.iter().map(|m| m.reason.clone()).collect(),
})
}
pub(crate) fn print_note(&self, so_what: &str, tail: &str) {
let _ = self.write_note(&mut std::io::stdout(), so_what, tail);
}
pub(crate) fn eprint_note(&self, so_what: &str, tail: &str) {
let _ = self.write_note(&mut std::io::stderr(), so_what, tail);
}
#[cfg(test)]
pub(crate) fn write_note_for_test(&self, w: &mut dyn std::io::Write, so_what: &str, tail: &str) {
let _ = self.write_note(w, so_what, tail);
}
fn write_note(&self, w: &mut dyn std::io::Write, so_what: &str, tail: &str) -> std::io::Result<()> {
if !self.must_hedge() {
return Ok(());
}
let mut head = match (self.units() > 0, self.judged_nothing.len()) {
(true, 0) => format!(
"the report(s) under this locator declare {} unit(s) candor could not analyze,",
self.units()
),
(true, n) => format!(
"the report(s) under this locator declare {} unit(s) candor could not analyze, and {n} \
report(s) that judged nothing at all,",
self.units()
),
(false, 0) => String::new(),
(false, n) => format!(
"{n} report(s) under this locator say they JUDGED NOTHING (`analyzed.count: 0`),"
),
};
fn append(head: &mut String, alone: String, joined: String) {
if head.is_empty() {
*head = alone;
} else {
head.pop(); head.push_str(&joined);
}
}
if let n @ 1.. = self.refused.len() {
append(
&mut head,
format!(
"{n} report(s) under this locator are marked REFUSED (SPEC §3.3.1 ⟨0.32⟩) — the \
most recent producing scan over them refused and never completed,"
),
format!(", and {n} report(s) under this locator marked REFUSED,"),
);
}
if let n @ 1.. = self.no_manifest.len() {
append(
&mut head,
format!(
"{n} report(s) under this locator carry NO `analyzed` manifest at all (SPEC §2 row \
3, a pre-⟨0.21⟩ producer),"
),
format!(", and {n} report(s) carrying NO `analyzed` manifest at all,"),
);
}
if let n @ 1.. = self.out_of_scope.len() {
append(
&mut head,
format!(
"the report(s) under this locator name {n} function(s) OUTSIDE the scan's scope \
performing an effect the producing scan's policy DENIED,"
),
format!(
", and {n} function(s) OUTSIDE the scan's scope performing a DENIED effect,"
),
);
}
if !self.unread.is_empty() {
let n = self.unread.len();
append(
&mut head,
format!(
"the report(s) under this locator declare {n} exclusion class(es) the scan did NOT \
READ (`excluded[].peeked: false`),"
),
format!(", and {n} exclusion class(es) the scan did NOT READ,"),
);
}
if !self.unasked_rules.is_empty() {
let n = self.unasked_rules.len();
if self.unasked_rules_predates_033 {
append(
&mut head,
format!(
"the report(s) under this locator predate ⟨0.33⟩ — before a producing scan \
recorded the deny set its peek ran under — so they cannot say whether {n} \
rule(s) of THIS policy were ever asked,"
),
format!(
", and {n} rule(s) of THIS policy the report(s) — from before ⟨0.33⟩ — cannot say \
they were asked about,"
),
);
} else {
append(
&mut head,
format!(
"the report(s) under this locator were peeked under a deny set that does not cover \
{n} rule(s) of THIS policy,"
),
format!(
", and {n} rule(s) of THIS policy the report(s) were peeked under a DIFFERENT deny \
set,"
),
);
}
}
writeln!(w, " ⚠ INCOMPLETE — {head}")?;
writeln!(w, " so {so_what}:")?;
for m in &self.refused {
writeln!(
w,
" {} — REFUSED: {}. `candor-query gate --report` refuses (exit 2) over these bytes \
unconditionally (SPEC §3.3.1 ⟨0.32⟩); re-scan to clear the marker",
m.prefix, m.reason
)?;
}
for u in &self.unanalyzed {
writeln!(w, " {} — {}", u.path, u.reason)?;
}
for u in &self.unreadable {
writeln!(w, " {} — its `unanalyzed` manifest could not be read (see above)", u.path)?;
}
for p in &self.judged_nothing {
writeln!(
w,
" {p} — `analyzed.count: 0`: this report judged NOTHING, so it names no function \
at all and its silence is not a purity claim"
)?;
}
for p in &self.no_manifest {
writeln!(
w,
" {p} — NO `analyzed` manifest at all (SPEC §2 row 3, a pre-⟨0.21⟩ producer): it \
DECLARES nothing about what was judged, so its silence licenses no purity claim \
either. Re-scan with a current engine so the report carries its manifest"
)?;
}
for o in &self.out_of_scope {
writeln!(
w,
" {} — OUTSIDE the producing scan's scope: it performs {}, and the gate did not \
judge it",
o.func,
o.effects.join(", ")
)?;
}
for c in &self.unread {
let remedy = if self.unread_armed {
"Re-run the producing scan WITH this policy (candor-scan <dir> --policy <p>)"
} else {
"Re-run the producing scan WITH a `deny`/`pure` policy so the peek reads it \
(candor-scan <dir> --policy <p>)"
};
writeln!(
w,
" {c} — this exclusion class went UNREAD (`excluded[].peeked: false`): its \
effects are absent because nothing looked, not because there are none. {remedy}"
)?;
}
if !self.unasked_rules.is_empty() {
let cause_and_remedy = if self.unasked_rules_predates_033 {
"these reports were produced before ⟨0.33⟩, when a producing scan did not yet record the \
deny set its peek ran under — so an empty finding there cannot be read as an answer to \
THIS policy's question. Re-scan with a 0.33+ engine under THE SAME policy this verb is \
applying (candor-scan <dir> --policy <p>) — not merely under a policy"
} else {
"never asked of the excluded files a peeked class's producer read: the empty finding \
there answers a DIFFERENT question, not this one. Re-run the producing scan under THE \
SAME policy this verb is applying (candor-scan <dir> --policy <p>) — not merely under a \
policy"
};
writeln!(w, " {} — {cause_and_remedy}", self.unasked_rules.join(", "))?;
}
writeln!(w, " {tail}")
}
}
pub(crate) fn report_completeness(prefix: &str) -> ReportCompleteness {
let mut out = ReportCompleteness {
unanalyzed: Vec::new(),
unreadable: Vec::new(),
judged_nothing: Vec::new(),
no_manifest: Vec::new(),
out_of_scope: Vec::new(),
unread: Vec::new(),
unread_armed: false,
scanned_under_facts: Vec::new(),
unasked_rules: Vec::new(),
unasked_rules_predates_033: false,
refused: Vec::new(),
};
if let Some(m) = candor_report::refusal_marker_for(prefix) {
out.refused.push(m);
}
for path in glob_reports(prefix) {
let p = path.display().to_string();
let Ok(text) = std::fs::read_to_string(&path) else {
out.unreadable.push(Unreadable { path: p, key_present: false });
continue;
};
match candor_report::report_unanalyzed(&text) {
candor_report::KeyRead::Present(u) => out.unanalyzed.extend(u),
candor_report::KeyRead::Absent => {}
candor_report::KeyRead::Corrupt => {
out.unreadable.push(Unreadable { path: p, key_present: true });
continue;
}
}
match candor_report::report_out_of_scope(&text) {
candor_report::KeyRead::Present(o) => out.out_of_scope.extend(o),
candor_report::KeyRead::Absent => {}
candor_report::KeyRead::Corrupt => {
out.unreadable.push(Unreadable { path: p, key_present: true });
continue;
}
}
let mut any_peeked = false;
match candor_report::report_excluded(&text) {
candor_report::KeyRead::Present(x) => {
any_peeked = x.iter().any(|e| e.peeked && !e.judged_elsewhere);
out.unread.extend(
x.into_iter().filter(|e| !e.peeked && !e.judged_elsewhere).map(|e| e.class),
);
}
candor_report::KeyRead::Absent => {}
candor_report::KeyRead::Corrupt => {
out.unreadable.push(Unreadable { path: p, key_present: true });
continue;
}
}
let spec = candor_report::report_spec(&text);
match candor_report::report_scanned_under(&text) {
candor_report::KeyRead::Present(d) => out.scanned_under_facts.push((any_peeked, Some(d), spec)),
candor_report::KeyRead::Absent => out.scanned_under_facts.push((any_peeked, None, spec)),
candor_report::KeyRead::Corrupt => {
out.unreadable.push(Unreadable { path: p, key_present: true });
continue;
}
}
if candor_report::report_judged_nothing(&text) {
if candor_report::report_has_no_manifest(&text) {
out.no_manifest.push(p);
} else {
out.judged_nothing.push(p);
}
}
}
out
}