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>,
}
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) 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
}
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
}
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;
}
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() },
})
}
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.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,"),
);
}
writeln!(w, " ⚠ INCOMPLETE — {head}")?;
writeln!(w, " so {so_what}:")?;
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}"
)?;
}
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,
};
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;
}
}
match candor_report::report_excluded(&text) {
candor_report::KeyRead::Present(x) => 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;
}
}
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
}