Files
DarkRoom/tools/traceability/src/verdicts.rs
T
dtourolle 2cde287440 Hold every verdict write to a reviewed list of user actions
FR-CULL-13 says evidence never writes a rating, flag, label or trash
membership, and nothing enforced it. tools/traceability/src/verdicts.rs
parses the shipped code with syn and enumerates every write: calls to
the catalog setters and trash recorders, SQL that assigns those columns,
sidecar Amendment::Judgement, and fields named rating/flag/label. Each
site must be in ALLOWED with a reason, as Input (inside a Slint on_*
closure, checked structurally), Relay (its callers are checked in
turn), Carried (a verdict made elsewhere: sidecar and XMP pulls, sync
merge, catalog mirrored to file, duplicates consolidation) or
NotAVerdict. Unlisted sites and stale entries both fail
`cargo test -p traceability`; `traces verdicts` prints the list.

syn and proc-macro2 were already in the lockfile as proc-macro
dependencies; this adds the edges, no new crate and no version change.
2026-09-27 07:20:38 -04:00

1848 lines
61 KiB
Rust

//! TRACES: FR-CULL-13 | R7
//! Every write of a verdict is a user's, and this is the list of them.
//!
//! # The rule
//!
//! FR-CULL-13: everything the app computes about a frame — clipping, focus,
//! burst membership, face and eye state, a duplicate's twin — is *evidence*,
//! and evidence never writes a rating, a flag, a colour label or trash
//! membership. The classifier does not hold the pen. R7 says the same thing
//! from the photographer's side.
//!
//! A rule like that is kept by nobody in particular: the well-meaning
//! shortcut ("reject the frames where every eye is shut") is one line in a
//! worker, and it compiles. So this module enumerates every place in the
//! shipped code that writes a verdict, and holds that enumeration to
//! [`ALLOWED`] — a list written by hand, one entry per call site, each with a
//! reason. A new writer anywhere fails the check until somebody adds it here
//! and says why, and the review of that line is the review the rule needs.
//!
//! # What counts as a write
//!
//! Scanned with `syn` over `core`, `ui`, `apps` and `platform`, test modules,
//! `tests/`, `examples/` and `benches/` left out:
//!
//! - a call to one of [`PRIMITIVES`] — `dr_catalog::rating`'s setters and the
//! trash recorders;
//! - SQL, in any string literal or macro, that assigns `versions.rating`,
//! `flag` or `label`, or `images.trashed_at` — whether by `UPDATE`, by an
//! upsert, or by an `INSERT` that gives them anything but `0` or `NULL`
//! (minting an unjudged row is the absence of a verdict, not one);
//! - building a sidecar `Amendment::Judgement`, which is how a verdict
//! reaches DarkRoom's own sidecar;
//! - assigning a field named `rating`, `flag` or `label` — the sidecar's
//! `Version` and the XMP record carry verdicts in fields of those names.
//!
//! # How an entry says what reaches it
//!
//! Each entry names the function (and the Slint callback closure, where
//! there is one, as `|on_name|`) that holds the write, and one of four
//! [`Kind`]s:
//!
//! - [`Kind::Input`] — the site sits inside a closure handed to an `on_*`
//! callback: a key, a click, a tap. Checked structurally: an `Input` entry
//! whose site is not inside such a closure is a failure, whatever its
//! reason says — so a timer or a worker cannot be listed as a press.
//! - [`Kind::Relay`] — the function writes on its caller's behalf. Its own
//! name becomes a writer, and *its* callers are enumerated and held to this
//! list in turn, until every chain ends in an `Input` or a `Carried`.
//! - [`Kind::Carried`] — the value was a verdict before it got here: a
//! sidecar or XMP written by the photographer on another device or in
//! another application, a catalog merge, the catalog's own state mirrored
//! out to a file, or consolidation, which moves verdicts the copies already
//! held onto the one that stays. Carried is the exemption, so its reason
//! has to say where the verdict was made.
//! - [`Kind::NotAVerdict`] — a field named `rating`, `flag` or `label` that
//! is not one: a filter's state, a grid cell's copy of the catalog, a clone
//! made to compare two edits.
//!
//! Which of `Relay` and `Carried` an entry is follows from where the value
//! comes from. A function that takes the verdict as an argument is a
//! `Relay` — the value is its caller's, so the caller is what has to be
//! justified. A function that reads the verdict out of a file, a remote
//! catalog or the copies being merged is `Carried`, and says so.
//!
//! Stale entries fail too: an entry nothing matches is a writer somebody
//! removed or renamed, and a list that only grows stops describing the code.
//!
//! # Limits
//!
//! Name-based, not type-based. Calls are matched by name and the module
//! they are reached through, following `use … as …` renames and `pub use`
//! re-exports; a method relay matches every `.name(` call, which is why the
//! peaking overlay's `set_colour` is listed as not a verdict. A struct
//! literal that fills a `rating` field is not seen (wgpu names every buffer
//! with a `label:` and the noise would bury the signal), and a writer reached
//! through a trait object is seen only where its name is spelled. A new
//! writer that is none of the shapes above should be added to [`PRIMITIVES`].
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use proc_macro2::{Delimiter, TokenStream, TokenTree};
use syn::visit::{self, Visit};
/// Directories scanned: the shipped code. `tools` is left out for the reason
/// the gesture scan leaves it out — this module's own fixtures are writers.
pub const ROOTS: &[&str] = &["core", "ui", "apps", "platform"];
/// Path components that mark code which never ships.
const NOT_SHIPPED: &[&str] = &["tests", "examples", "benches", "target"];
/// Why a write is allowed. See the module documentation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// Inside a Slint `on_*` callback: a key, a click, a tap.
Input,
/// Writes for its caller; the caller is checked in its place.
Relay,
/// A verdict made elsewhere, moved or mirrored, never made here.
Carried,
/// A field that shares a verdict's name and holds something else: a
/// filter, a display model's copy, a scratch clone.
NotAVerdict,
}
/// One allowed write site.
#[derive(Debug, Clone, Copy)]
pub struct Allowed {
/// Repository-relative path of the file.
pub file: &'static str,
/// The enclosing function, then any `on_*` callback closure written as
/// `|on_name|`, joined with ` > `; a method is `Type::method`.
pub within: &'static str,
/// What it writes: a writer as `module::name`, `sql versions`,
/// `sql images`, `Amendment::Judgement` or `field .rating`.
pub writes: &'static str,
pub kind: Kind,
/// The justification a reviewer reads. Not checked, but required.
pub why: &'static str,
}
/// A function whose call is a write.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Writer {
/// The module a qualified call names it through — the file stem, or the
/// type for a method.
pub module: String,
pub name: String,
/// Where it is defined. A bare call matches only here or where the file
/// imports it.
pub file: String,
/// A method: matched on `.name(` anywhere, or `Self::name(` in its own
/// file. Anywhere, because a method's callers are in other files; so a
/// method is a `Relay` only when its name is its own.
pub method: bool,
}
impl Writer {
fn display(&self) -> String {
format!("{}::{}", self.module, self.name)
}
}
/// The primitive verdict writers: `(file, module, fn)`.
pub const PRIMITIVES: &[(&str, &str, &str)] = &[
("core/dr-catalog/src/rating.rs", "rating", "set_rating"),
("core/dr-catalog/src/rating.rs", "rating", "set_flag"),
("core/dr-catalog/src/rating.rs", "rating", "set_label"),
("core/dr-catalog/src/rating.rs", "rating", "set_rating_many"),
("core/dr-catalog/src/rating.rs", "rating", "set_flag_many"),
("core/dr-catalog/src/rating.rs", "rating", "set_label_many"),
("core/dr-catalog/src/trash.rs", "trash", "record_trashed"),
(
"core/dr-catalog/src/trash.rs",
"trash",
"record_trashed_within",
),
("core/dr-catalog/src/trash.rs", "trash", "record_restored"),
];
/// The columns that hold a verdict, by table.
const VERDICT_COLUMNS: &[(&str, &[&str])] = &[
("versions", &["rating", "flag", "label"]),
("images", &["trashed_at"]),
];
/// Field names that carry a verdict in the sidecar and XMP records.
const VERDICT_FIELDS: &[&str] = &["rating", "flag", "label"];
/// The sidecar amendment that carries a judgement, as `(type, variant)`.
const JUDGEMENT_AMENDMENT: (&str, &str) = ("Amendment", "Judgement");
/// One write found in the source.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Site {
pub file: String,
pub line: usize,
pub within: String,
pub writes: String,
/// For SQL, the verdict columns it assigns.
pub detail: String,
}
/// What a check found.
#[derive(Debug, Default)]
pub struct Report {
pub sites: Vec<Site>,
pub problems: Vec<String>,
}
/// Read the shipped sources under `base`, as `(relative path, text)`.
pub fn sources(base: &Path) -> Vec<(String, String)> {
crate::collect_sources(base, ROOTS)
.into_iter()
.filter(|p| p.extension().is_some_and(|e| e == "rs"))
.filter_map(|p| {
let rel = p
.strip_prefix(base)
.ok()?
.to_string_lossy()
.replace('\\', "/");
if rel.split('/').any(|c| NOT_SHIPPED.contains(&c)) {
return None;
}
Some((rel, std::fs::read_to_string(&p).ok()?))
})
.collect()
}
/// Enumerate every verdict write in `files` and hold it to `allowed`.
pub fn check(files: &[(String, String)], allowed: &[Allowed]) -> Report {
check_with(files, PRIMITIVES, allowed)
}
/// [`check`] with the primitive writers named, for the fixtures.
pub fn check_with(
files: &[(String, String)],
primitives: &[(&str, &str, &str)],
allowed: &[Allowed],
) -> Report {
let mut report = Report::default();
if files.is_empty() {
report
.problems
.push("no source files scanned — misconfigured, not zero writers".into());
return report;
}
let mut parsed = Vec::new();
for (rel, text) in files {
match syn::parse_file(text) {
Ok(ast) => parsed.push((rel.as_str(), ast)),
// Loud: a file this cannot read is a file whose writers it
// cannot see.
Err(e) => report.problems.push(format!("{rel}: does not parse ({e})")),
}
}
let mut writers: BTreeSet<Writer> = BTreeSet::new();
for (file, module, name) in primitives {
let defined = parsed
.iter()
.find(|(rel, _)| rel == file)
.is_some_and(|(_, ast)| defines_fn(ast, name));
if !defined {
report.problems.push(format!(
"{file} no longer defines `{name}` — update PRIMITIVES, or every \
caller of its replacement goes unchecked"
));
}
writers.insert(Writer {
module: (*module).into(),
name: (*name).into(),
file: (*file).into(),
method: false,
});
}
let by_key: BTreeMap<(&str, &str, &str), &Allowed> = allowed
.iter()
.map(|a| ((a.file, a.within, a.writes), a))
.collect();
// To a fixed point: every relay's callers are sites in turn.
let mut sites: BTreeSet<Site> = BTreeSet::new();
loop {
let before = writers.len();
sites.clear();
for (rel, ast) in &parsed {
let mut scan = Scan::new(rel, ast, &writers);
scan.visit_file(ast);
sites.extend(scan.sites);
}
for site in &sites {
let key = (
site.file.as_str(),
site.within.as_str(),
site.writes.as_str(),
);
if let Some(a) = by_key.get(&key) {
if a.kind == Kind::Relay {
if let Some(w) = relay_writer(site) {
writers.insert(w);
}
}
}
}
// A `pub use` that re-exports a writer is the writer under the
// re-exporting module's name: `crate::library_ui::x` is
// `ratings_keywords::x` once `library_ui` says so.
for (rel, ast) in &parsed {
for item in &ast.items {
let syn::Item::Use(u) = item else { continue };
if matches!(u.vis, syn::Visibility::Inherited) {
continue;
}
let mut uses = Uses::default();
collect_use(&u.tree, &mut Vec::new(), &mut uses);
for (module, name, local) in uses.names {
let hit = writers
.iter()
.any(|w| !w.method && w.module == module && w.name == name);
if hit {
writers.insert(Writer {
module: module_of(rel),
name: local,
file: (*rel).to_string(),
method: false,
});
}
}
}
}
if writers.len() == before {
break;
}
}
let mut matched: BTreeSet<(&str, &str, &str)> = BTreeSet::new();
for site in &sites {
let key = (
site.file.as_str(),
site.within.as_str(),
site.writes.as_str(),
);
match by_key.get(&key) {
None => report.problems.push(format!(
"{}:{}: `{}` writes a verdict ({}{}) and is not in verdicts::ALLOWED. \
If a person's key, click or tap is what reaches it, add it with the \
reason; if a signal is, it must not write (FR-CULL-13)",
site.file,
site.line,
site.within,
site.writes,
if site.detail.is_empty() {
String::new()
} else {
format!(": {}", site.detail)
},
)),
Some(a) => {
matched.insert((a.file, a.within, a.writes));
if a.kind == Kind::Input && !site.within.split(" > ").any(|s| s.starts_with("|on_"))
{
report.problems.push(format!(
"{}:{}: `{}` is allowed as Input but is not inside an `on_*` callback",
site.file, site.line, site.within
));
}
if a.kind == Kind::Relay && relay_writer(site).is_none() {
report.problems.push(format!(
"{}:{}: `{}` is a Relay but not inside a named function",
site.file, site.line, site.within
));
}
}
}
}
for a in allowed {
if !matched.contains(&(a.file, a.within, a.writes)) {
report.problems.push(format!(
"verdicts::ALLOWED names `{}` in {} writing {}, and no such write exists — \
remove the entry",
a.within, a.file, a.writes
));
}
}
report.sites = sites.into_iter().collect();
report
}
/// The writer a relay site's enclosing function becomes.
fn relay_writer(site: &Site) -> Option<Writer> {
let func = site
.within
.rsplit(" > ")
.find(|s| !s.starts_with('|') && !s.starts_with("const "))?;
let (module, name, method) = match func.rsplit_once("::") {
Some((ty, m)) => (ty.to_string(), m.to_string(), true),
None => (module_of(&site.file), func.to_string(), false),
};
Some(Writer {
module,
name,
file: site.file.clone(),
method,
})
}
/// The module name a file is reached through.
fn module_of(file: &str) -> String {
let mut parts = file.rsplit('/');
let stem = parts
.next()
.unwrap_or_default()
.trim_end_matches(".rs")
.to_string();
if stem == "mod" || stem == "lib" || stem == "main" {
parts.next().unwrap_or_default().replace('-', "_")
} else {
stem
}
}
fn defines_fn(ast: &syn::File, name: &str) -> bool {
ast.items
.iter()
.any(|i| matches!(i, syn::Item::Fn(f) if f.sig.ident == name))
}
/// Test code: `#[test]`, `#[cfg(test)]` and the like, but not
/// `#[cfg(not(test))]`.
fn is_test(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|a| {
let path = a.path();
if path.segments.last().is_some_and(|s| s.ident == "test") {
return true;
}
if path.is_ident("cfg") {
let text = a.meta.to_token_stream_string();
return text.contains("test") && !text.contains("not");
}
false
})
}
trait MetaText {
fn to_token_stream_string(&self) -> String;
}
impl MetaText for syn::Meta {
fn to_token_stream_string(&self) -> String {
match self {
syn::Meta::List(l) => l.tokens.to_string(),
_ => String::new(),
}
}
}
/// What a file's `use` declarations bring into scope, wherever they sit.
/// Scoping is ignored: an import inside one function counts for the file,
/// which can only make the check see more.
#[derive(Default)]
struct Uses {
/// `(module, name, local name)`: `use a::rating::set_flag as sf` is
/// `("rating", "set_flag", "sf")`.
names: BTreeSet<(String, String, String)>,
/// A module imported under another name: `use crate::duplicates as dups`
/// maps `dups` to `duplicates`.
renamed: BTreeMap<String, String>,
/// Modules glob-imported.
globs: BTreeSet<String>,
}
impl<'ast> Visit<'ast> for Uses {
fn visit_item_use(&mut self, u: &'ast syn::ItemUse) {
let mut into = Uses::default();
collect_use(&u.tree, &mut Vec::new(), &mut into);
self.names.extend(into.names);
self.renamed.extend(into.renamed);
self.globs.extend(into.globs);
}
}
struct Scan<'a> {
file: &'a str,
writers: &'a BTreeSet<Writer>,
imports: Uses,
ctx: Vec<String>,
impl_ty: Vec<String>,
sites: Vec<Site>,
}
impl<'a> Scan<'a> {
fn new(file: &'a str, ast: &syn::File, writers: &'a BTreeSet<Writer>) -> Self {
let mut imports = Uses::default();
imports.visit_file(ast);
Self {
file,
writers,
imports,
ctx: Vec::new(),
impl_ty: Vec::new(),
sites: Vec::new(),
}
}
fn within(&self) -> String {
self.ctx.join(" > ")
}
fn push(&mut self, line: usize, writes: String, detail: String) {
self.sites.push(Site {
file: self.file.to_string(),
line,
within: self.within(),
writes,
detail,
});
}
/// The writer a path names, if any. `segments` are the path's idents.
fn path_writer(&self, segments: &[String]) -> Option<&'a Writer> {
let name = segments.last()?;
self.writers.iter().find(|w| {
// A bare name may be a writer imported under another name.
if segments.len() == 1 && &w.name != name {
return self.imports.names.contains(&(
w.module.clone(),
w.name.clone(),
name.clone(),
));
}
if &w.name != name {
return false;
}
if segments.len() >= 2 {
let q = &segments[segments.len() - 2];
let q = self.imports.renamed.get(q).unwrap_or(q);
return q == &w.module || (w.method && q == "Self" && w.file == self.file);
}
!w.method
&& (w.file == self.file
|| self.imports.globs.contains(&w.module)
|| self.imports.names.contains(&(
w.module.clone(),
w.name.clone(),
name.clone(),
)))
})
}
fn check_sql(&mut self, text: &str, line: usize) {
for (table, columns) in sql_writes(text) {
self.push(line, format!("sql {table}"), columns.join(", "));
}
}
/// Macro bodies are tokens, not expressions: look for SQL in their
/// literals and writer names followed by an argument list.
fn scan_tokens(&mut self, stream: TokenStream) {
let tokens: Vec<TokenTree> = stream.into_iter().collect();
for (i, t) in tokens.iter().enumerate() {
match t {
TokenTree::Group(g) => self.scan_tokens(g.stream()),
TokenTree::Literal(l) => {
if let Ok(s) = syn::parse_str::<syn::LitStr>(&l.to_string()) {
self.check_sql(&s.value(), l.span().start().line);
}
}
TokenTree::Ident(id) => {
let called = matches!(tokens.get(i + 1),
Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Parenthesis);
if !called {
continue;
}
let mut segments = vec![id.to_string()];
let mut j = i;
while j >= 3 {
match (&tokens[j - 1], &tokens[j - 2], &tokens[j - 3]) {
(TokenTree::Punct(a), TokenTree::Punct(b), TokenTree::Ident(q))
if a.as_char() == ':' && b.as_char() == ':' =>
{
segments.insert(0, q.to_string());
j -= 3;
}
_ => break,
}
}
let method = j >= 1
&& matches!(&tokens[j - 1], TokenTree::Punct(p) if p.as_char() == '.');
let hit = if method {
self.writers
.iter()
.find(|w| w.method && w.name == segments[0])
} else {
self.path_writer(&segments)
};
if let Some(w) = hit {
self.push(id.span().start().line, w.display(), String::new());
}
}
TokenTree::Punct(_) => {}
}
}
}
}
fn collect_use(tree: &syn::UseTree, prefix: &mut Vec<String>, out: &mut Uses) {
match tree {
syn::UseTree::Path(p) => {
prefix.push(p.ident.to_string());
collect_use(&p.tree, prefix, out);
prefix.pop();
}
syn::UseTree::Name(n) => {
if let Some(m) = prefix.last() {
let name = n.ident.to_string();
out.names.insert((m.clone(), name.clone(), name));
}
}
syn::UseTree::Rename(r) => {
let (name, local) = (r.ident.to_string(), r.rename.to_string());
// `use crate::duplicates::{self as dups}` renames the module the
// path ends in.
if name == "self" {
if let Some(m) = prefix.last() {
out.renamed.insert(local, m.clone());
}
return;
}
if let Some(m) = prefix.last() {
out.names.insert((m.clone(), name.clone(), local.clone()));
}
out.renamed.insert(local, name);
}
syn::UseTree::Glob(_) => {
if let Some(m) = prefix.last() {
out.globs.insert(m.clone());
}
}
syn::UseTree::Group(g) => {
for t in &g.items {
collect_use(t, prefix, out);
}
}
}
}
impl<'ast> Visit<'ast> for Scan<'_> {
fn visit_item_mod(&mut self, m: &'ast syn::ItemMod) {
if !is_test(&m.attrs) {
visit::visit_item_mod(self, m);
}
}
fn visit_item_fn(&mut self, f: &'ast syn::ItemFn) {
if is_test(&f.attrs) {
return;
}
self.ctx.push(f.sig.ident.to_string());
visit::visit_item_fn(self, f);
self.ctx.pop();
}
fn visit_item_impl(&mut self, i: &'ast syn::ItemImpl) {
if is_test(&i.attrs) {
return;
}
let ty = match &*i.self_ty {
syn::Type::Path(p) => p
.path
.segments
.last()
.map(|s| s.ident.to_string())
.unwrap_or_default(),
_ => String::new(),
};
self.impl_ty.push(ty);
visit::visit_item_impl(self, i);
self.impl_ty.pop();
}
fn visit_impl_item_fn(&mut self, f: &'ast syn::ImplItemFn) {
if is_test(&f.attrs) {
return;
}
let ty = self.impl_ty.last().cloned().unwrap_or_default();
self.ctx.push(format!("{ty}::{}", f.sig.ident));
visit::visit_impl_item_fn(self, f);
self.ctx.pop();
}
fn visit_trait_item_fn(&mut self, f: &'ast syn::TraitItemFn) {
self.ctx.push(f.sig.ident.to_string());
visit::visit_trait_item_fn(self, f);
self.ctx.pop();
}
fn visit_item_const(&mut self, c: &'ast syn::ItemConst) {
self.ctx.push(format!("const {}", c.ident));
visit::visit_item_const(self, c);
self.ctx.pop();
}
fn visit_item_static(&mut self, s: &'ast syn::ItemStatic) {
self.ctx.push(format!("const {}", s.ident));
visit::visit_item_static(self, s);
self.ctx.pop();
}
fn visit_expr_method_call(&mut self, m: &'ast syn::ExprMethodCall) {
let name = m.method.to_string();
if let Some(w) = self.writers.iter().find(|w| w.method && w.name == name) {
self.push(m.method.span().start().line, w.display(), String::new());
}
// A closure handed to `on_*` is a Slint callback: what runs inside it
// runs because a person did something.
if name.starts_with("on_") {
self.visit_expr(&m.receiver);
for arg in &m.args {
if matches!(arg, syn::Expr::Closure(_)) {
self.ctx.push(format!("|{name}|"));
self.visit_expr(arg);
self.ctx.pop();
} else {
self.visit_expr(arg);
}
}
return;
}
visit::visit_expr_method_call(self, m);
}
fn visit_expr_path(&mut self, p: &'ast syn::ExprPath) {
let segments: Vec<String> = p
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect();
if let Some(w) = self.path_writer(&segments) {
let line = p
.path
.segments
.last()
.map(|s| s.ident.span().start().line)
.unwrap_or(0);
self.push(line, w.display(), String::new());
}
visit::visit_expr_path(self, p);
}
fn visit_expr_struct(&mut self, s: &'ast syn::ExprStruct) {
let segs: Vec<String> = s
.path
.segments
.iter()
.map(|s| s.ident.to_string())
.collect();
let (ty, variant) = JUDGEMENT_AMENDMENT;
if segs.len() >= 2 && segs[segs.len() - 2] == ty && segs[segs.len() - 1] == variant {
let line = s.path.segments[0].ident.span().start().line;
self.push(line, format!("{ty}::{variant}"), String::new());
}
visit::visit_expr_struct(self, s);
}
fn visit_expr_assign(&mut self, a: &'ast syn::ExprAssign) {
if let syn::Expr::Field(f) = &*a.left {
if let syn::Member::Named(n) = &f.member {
if VERDICT_FIELDS.iter().any(|v| n == v) {
self.push(n.span().start().line, format!("field .{n}"), String::new());
}
}
}
visit::visit_expr_assign(self, a);
}
fn visit_lit_str(&mut self, s: &'ast syn::LitStr) {
self.check_sql(&s.value(), s.span().start().line);
}
fn visit_macro(&mut self, m: &'ast syn::Macro) {
self.scan_tokens(m.tokens.clone());
}
}
// ---- SQL ------------------------------------------------------------------
/// Split SQL into the tokens the statement reader needs: words (with
/// `{db}` and `?2` kept whole), `.`, `(`, `)`, `,`, `=`, `;`, and comparison
/// operators as one token so `>=` is never read as an assignment.
fn sql_tokens(sql: &str) -> Vec<String> {
let mut out = Vec::new();
let mut chars = sql.chars().peekable();
// Line comments first: `--` to the end of the line says nothing.
while let Some(c) = chars.next() {
if c == '-' && chars.peek() == Some(&'-') {
for c in chars.by_ref() {
if c == '\n' {
break;
}
}
} else if c.is_alphanumeric() || "_{}?$:".contains(c) {
let mut w = c.to_lowercase().to_string();
while let Some(&n) = chars.peek() {
if n.is_alphanumeric() || "_{}?$:".contains(n) {
w.extend(n.to_lowercase());
chars.next();
} else {
break;
}
}
out.push(w);
} else if "<>!".contains(c) {
let mut op = c.to_string();
while let Some(&n) = chars.peek() {
if "<>!=".contains(n) {
op.push(n);
chars.next();
} else {
break;
}
}
out.push(op);
} else if ".(),=;".contains(c) {
out.push(c.to_string());
} else if c == '\'' {
// A quoted value is one opaque word.
for c in chars.by_ref() {
if c == '\'' {
break;
}
}
out.push("'".into());
}
}
out
}
fn is_word(t: &str) -> bool {
t.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || "_{?$:".contains(c))
}
fn watched(table: &str) -> Option<&'static [&'static str]> {
VERDICT_COLUMNS
.iter()
.find(|(t, _)| *t == table)
.map(|(_, c)| *c)
}
/// The verdict columns each statement in `sql` assigns, by table.
pub fn sql_writes(sql: &str) -> Vec<(String, Vec<String>)> {
let lower = sql.to_lowercase();
if !(lower.contains("update") || lower.contains("insert") || lower.contains("replace")) {
return Vec::new();
}
let toks = sql_tokens(sql);
let mut out: Vec<(String, Vec<String>)> = Vec::new();
let mut insert_table = String::new();
let mut record = |table: &str, cols: Vec<String>| {
if cols.is_empty() {
return;
}
match out.iter_mut().find(|(t, _)| t == table) {
Some((_, have)) => {
for c in cols {
if !have.contains(&c) {
have.push(c);
}
}
}
None => out.push((table.to_string(), cols)),
}
};
let mut i = 0;
while i < toks.len() {
let t = toks[i].as_str();
if t == "into" {
// `INSERT [OR …] INTO [schema.]table (cols) VALUES (vals)`.
let mut j = i + 1;
let mut table = String::new();
while j < toks.len() && (is_word(&toks[j]) || toks[j] == ".") {
if is_word(&toks[j]) {
table = toks[j].clone();
}
j += 1;
if toks.get(j).map(String::as_str) != Some(".") {
break;
}
}
insert_table = table.clone();
if let (Some(cols_of), Some("(")) = (watched(&table), toks.get(j).map(String::as_str)) {
let (cols, next) = list(&toks, j);
let vals = if toks.get(next).map(String::as_str) == Some("values")
&& toks.get(next + 1).map(String::as_str) == Some("(")
{
list(&toks, next + 1).0
} else {
Vec::new()
};
let wrote: Vec<String> = cols
.iter()
.enumerate()
.filter(|(_, c)| cols_of.contains(&c.as_str()))
.filter(|(k, _)| {
// Minting an unjudged row is not a verdict.
!matches!(vals.get(*k).map(String::as_str), Some("0") | Some("null"))
})
.map(|(_, c)| c.clone())
.collect();
record(&table, wrote);
} else if let Some(cols_of) = watched(&table) {
// No column list: every column, verdicts included.
record(&table, cols_of.iter().map(|c| c.to_string()).collect());
}
i = j;
continue;
}
if t == "update" {
let mut j = i + 1;
let mut table = String::new();
while j < toks.len() && toks[j] != "set" && j < i + 8 {
if is_word(&toks[j])
&& !["or", "ignore", "replace", "abort", "fail", "rollback"]
.contains(&toks[j].as_str())
{
table = toks[j].clone();
}
j += 1;
}
if toks.get(j).map(String::as_str) != Some("set") {
i += 1;
continue;
}
if table.is_empty() {
// `ON CONFLICT … DO UPDATE SET`: the insert's own table.
table = insert_table.clone();
}
let (cols, next) = set_clause(&toks, j + 1);
if let Some(cols_of) = watched(&table) {
record(
&table,
cols.into_iter()
.filter(|c| cols_of.contains(&c.as_str()))
.collect(),
);
}
i = next;
continue;
}
i += 1;
}
out
}
/// A parenthesised, comma-separated list starting at `open`: each element's
/// first token, and the index after the closing paren.
fn list(toks: &[String], open: usize) -> (Vec<String>, usize) {
let mut out = Vec::new();
let mut depth = 0;
let mut first = true;
let mut k = open;
while k < toks.len() {
match toks[k].as_str() {
"(" => {
depth += 1;
if depth == 1 {
first = true;
k += 1;
continue;
}
}
")" => {
depth -= 1;
if depth == 0 {
return (out, k + 1);
}
}
"," if depth == 1 => {
first = true;
k += 1;
continue;
}
_ => {}
}
if first && depth == 1 {
out.push(toks[k].clone());
first = false;
} else if depth == 1 && !first {
// A value longer than one token is never the literal 0.
if let Some(last) = out.last_mut() {
if !last.ends_with('…') {
last.push('…');
}
}
}
k += 1;
}
(out, k)
}
/// The columns a `SET` clause assigns, and the index where it ends.
fn set_clause(toks: &[String], start: usize) -> (Vec<String>, usize) {
let mut cols = Vec::new();
let mut depth = 0i32;
let mut case = 0i32;
let mut expect = true;
let mut k = start;
while k < toks.len() {
let t = toks[k].as_str();
if depth == 0
&& case == 0
&& ["where", "from", "returning", ";", "on", "values"].contains(&t)
{
break;
}
match t {
"(" => depth += 1,
")" => {
if depth == 0 {
break;
}
depth -= 1;
}
"case" => case += 1,
"end" => case -= 1,
"," if depth == 0 && case == 0 => {
expect = true;
k += 1;
continue;
}
_ => {}
}
if expect && depth == 0 && case == 0 && is_word(t) {
if toks.get(k + 1).map(String::as_str) == Some("=") {
cols.push(t.to_string());
}
expect = false;
}
k += 1;
}
(cols, k)
}
// ---- the list ---------------------------------------------------------------
/// Every allowed verdict write in the shipped code. See the module
/// documentation for the kinds; each entry's `why` is the review.
///
/// Grouped by where the verdict comes from: the catalog's own writers, then
/// a person's gestures, then the paths that carry a verdict made elsewhere,
/// then the fields that only share a name.
pub const ALLOWED: &[Allowed] = &[
// ---- the catalog's writers: each takes the verdict as an argument ----
allow(
Kind::Relay,
CATALOG_RATING,
"set_rating",
"sql versions",
"Writes the star it is handed; its callers are checked.",
),
allow(
Kind::Relay,
CATALOG_RATING,
"set_flag",
"sql versions",
"Writes the flag it is handed; its callers are checked.",
),
allow(
Kind::Relay,
CATALOG_RATING,
"set_label",
"sql versions",
"Writes the label it is handed; its callers are checked.",
),
allow(
Kind::Relay,
CATALOG_RATING,
"set_rating_many",
"rating::set_rating",
"The one-transaction form of set_rating over a selection.",
),
allow(
Kind::Relay,
CATALOG_RATING,
"set_flag_many",
"rating::set_flag",
"The one-transaction form of set_flag over a selection.",
),
allow(
Kind::Relay,
CATALOG_RATING,
"set_label_many",
"rating::set_label",
"The one-transaction form of set_label over a selection.",
),
allow(
Kind::Relay,
CATALOG_TRASH,
"record_trashed_within",
"sql images",
"Records the moves it is handed as trashed; its callers are checked.",
),
allow(
Kind::Relay,
CATALOG_TRASH,
"record_trashed",
"trash::record_trashed_within",
"The own-transaction form of record_trashed_within.",
),
allow(
Kind::Relay,
CATALOG_TRASH,
"record_restored",
"sql images",
"Takes the images it is handed out of the trash; its callers are checked.",
),
// ---- the judgement dispatch: a person's key, click or tap ----
allow(
Kind::Relay,
UI_JUDGE,
"apply_judgement",
"rating::set_rating_many",
"The judgement dispatch (R7): stars for the images its gesture names.",
),
allow(
Kind::Relay,
UI_JUDGE,
"apply_judgement",
"rating::set_flag_many",
"The judgement dispatch (R7): pick or reject for the images its gesture names.",
),
allow(
Kind::Relay,
UI_JUDGE,
"apply_label",
"rating::set_label_many",
"The label dispatch (R7): the colour its gesture names.",
),
allow(
Kind::Input,
UI_JUDGE,
"wire_ratings_and_flags > |on_library_cell_rated|",
"ratings_keywords::apply_judgement",
"A star clicked or tapped on a grid cell, or develop's top-bar stars: that one photograph.",
),
allow(
Kind::Input,
UI_JUDGE,
"wire_ratings_and_flags > |on_library_cell_flagged|",
"ratings_keywords::apply_judgement",
"Develop's Pick and Reject buttons and its P, X and U keys: the open photograph.",
),
allow(
Kind::Input,
UI_JUDGE,
"wire_ratings_and_flags > |on_library_judged|",
"ratings_keywords::apply_judgement",
"A rating or flag key in the grid, or the selection bar's Flag: the selection, or \
the frame under the pointer.",
),
allow(
Kind::Input,
UI_JUDGE,
"wire_ratings_and_flags > |on_library_labelled|",
"ratings_keywords::apply_label",
"A label key in the grid (6 to 9 and the rest): the selection, toggled.",
),
allow(
Kind::Input,
UI_JUDGE,
"wire_ratings_and_flags > |on_library_label_chosen|",
"ratings_keywords::apply_label",
"The selection bar's label picker: the selection, set outright.",
),
allow(
Kind::Input,
UI_JUDGE,
"wire_ratings_and_flags > |on_library_cell_labelled|",
"ratings_keywords::apply_label",
"Develop's label keys and picker: the open photograph.",
),
// ---- trash membership ----
allow(
Kind::Relay,
CATALOG_DUPLICATES,
"consolidate",
"trash::record_trashed_within",
"Consolidation trashes the copies of a group in the transaction that merges \
them; handed the copies, so its callers are checked.",
),
allow(
Kind::Relay,
UI_TRASH,
"spawn_move",
"trash::record_trashed",
"The trash job records the moves that succeeded; its callers are checked.",
),
allow(
Kind::Relay,
UI_TRASH,
"spawn_move",
"trash::record_restored",
"The restore job records the moves that succeeded; its callers are checked.",
),
allow(
Kind::Relay,
UI_DUPLICATES,
"consolidate_group",
"duplicates::consolidate",
"Consolidates one reviewed group; its callers are checked.",
),
allow(
Kind::Relay,
UI_COLL_TRASH,
"start_trash",
"trash::spawn_move",
"Starts the trash job for the images it is handed; its callers are checked.",
),
allow(
Kind::Relay,
UI_COLL_TRASH,
"start_restore",
"trash::spawn_move",
"Starts the restore job for the images it is handed; its callers are checked.",
),
allow(
Kind::Input,
UI_COLL_GRID,
"wire_drag > |on_library_drag_finished|",
"trash::start_trash",
"A drag of photographs dropped on the sidebar's Trash row.",
),
allow(
Kind::Input,
UI_COLL_GRID,
"wire_trash > |on_trash_restore|",
"trash::start_restore",
"Restore, pressed in the trash view.",
),
allow(
Kind::Input,
UI_COLL_GRID,
"wire_trash_from_grid > |on_library_cell_trashed|",
"trash::start_trash",
"The trash target on one cell's rating strip: that photograph.",
),
allow(
Kind::Input,
UI_COLL_GRID,
"wire_trash_from_grid > |on_library_trash_selection|",
"trash::start_trash",
"Delete on the selection.",
),
allow(
Kind::Relay,
UI_DUPLICATES,
"run_consolidate",
"duplicates::consolidate_group",
"The consolidation worker, over the plans it is handed; its callers are checked.",
),
allow(
Kind::Relay,
UI_DUPLICATES,
"spawn_consolidate",
"duplicates::run_consolidate",
"Puts the consolidation worker on a thread; its callers are checked.",
),
allow(
Kind::Relay,
UI_DUPLICATES_SCREEN,
"start_consolidate",
"duplicates::spawn_consolidate",
"Starts consolidating the reviewed groups; its callers are checked.",
),
allow(
Kind::Input,
UI_DUPLICATES_SCREEN,
"wire > |on_confirm|",
"duplicates_ui::start_consolidate",
"Consolidate, pressed on the duplicates review after it has shown what each \
group will keep and trash.",
),
// ---- carried: a verdict made elsewhere ----
allow(
Kind::Carried,
CATALOG_DUPLICATES,
"merge_within",
"sql versions",
"Consolidation: the survivor takes the highest rating any copy was given and a \
flag or label only where the copies that carry one agree -- values read from \
the copies inside the same transaction, never computed. A disagreement leaves \
the survivor's own and is reported, so nothing is invented. Reached from the \
Consolidate press, and its preview rolls back.",
),
allow(
Kind::Carried,
SIDECAR,
"Version::merge",
"field .rating",
"Sync: two devices' ratings for one version, the higher revision winning; both \
were given by the photographer, and a zero never erases a star.",
),
allow(
Kind::Carried,
SIDECAR,
"Version::merge",
"field .flag",
"Sync: as the rating -- a flag set on another device.",
),
allow(
Kind::Carried,
SIDECAR,
"Version::merge",
"field .label",
"Sync: as the rating -- a label set on another device.",
),
allow(
Kind::Carried,
SIDECAR,
"Sidecar::parse",
"field .rating",
"Reading a sidecar file: the rating it records.",
),
allow(
Kind::Carried,
SIDECAR,
"Sidecar::parse",
"field .flag",
"Reading a sidecar file: the flag it records.",
),
allow(
Kind::Carried,
SIDECAR,
"Sidecar::parse",
"field .label",
"Reading a sidecar file: the label it records.",
),
allow(
Kind::Relay,
XMP,
"Xmp::set_colour",
"field .label",
"Sets the label it is handed on an XMP record; its callers are checked.",
),
allow(
Kind::Relay,
XMP,
"Xmp::set_values",
"field .rating",
"Sets a field from the values it is handed; its callers are checked.",
),
allow(
Kind::Relay,
XMP,
"Xmp::set_values",
"field .label",
"Sets a field from the values it is handed; its callers are checked.",
),
allow(
Kind::Carried,
UI_DUPLICATES,
"carry_edit",
"field .rating",
"Carrying a copy's edit onto the survivor keeps the survivor's own rating.",
),
allow(
Kind::Carried,
UI_DUPLICATES,
"carry_edit",
"field .flag",
"Carrying a copy's edit onto the survivor keeps the survivor's own flag.",
),
allow(
Kind::Carried,
UI_DUPLICATES,
"carry_edit",
"field .label",
"Carrying a copy's edit onto the survivor keeps the survivor's own label.",
),
allow(
Kind::Relay,
UI_SCAN,
"apply_judgement",
"sql versions",
"Takes a sidecar's judgement into the catalog; handed the values, so its \
callers are checked.",
),
allow(
Kind::Carried,
UI_SIDECAR,
"amend",
"field .rating",
"Applies an Amendment::Judgement to the sidecar; every construction of one is \
in this list.",
),
allow(
Kind::Carried,
UI_SIDECAR,
"amend",
"field .flag",
"Applies an Amendment::Judgement to the sidecar; every construction of one is \
in this list.",
),
allow(
Kind::Carried,
UI_SIDECAR,
"amend",
"field .label",
"Applies an Amendment::Judgement to the sidecar; every construction of one is \
in this list.",
),
allow(
Kind::Carried,
UI_JUDGE,
"collect_sidecar_writes",
"Amendment::Judgement",
"Mirrors what the catalog holds out to the sidecar: rating, flag and label are \
read from the versions row, after the write that put them there.",
),
allow(
Kind::Carried,
UI_XMP,
"record_of",
"field .rating",
"Mirrors the catalog's rating and flag into the XMP record written beside \
the original.",
),
allow(
Kind::Relay,
UI_XMP,
"apply",
"sql versions",
"Writes the XMP record it is handed onto the catalog; its callers are checked.",
),
allow(
Kind::Carried,
UI_XMP,
"carry_through",
"field .label",
"Keeps the label text the existing .xmp file holds when the catalog has none \
of the five colours for it.",
),
allow(
Kind::Carried,
XMP,
"reconcile",
"Xmp::set_values",
"Merges the catalog's record with the file's, field by field; each value it \
sets is one side's, and a disagreement is recorded, not resolved.",
),
allow(
Kind::Carried,
XMP_READ,
"parse",
"Xmp::set_values",
"Reading an .xmp file: the rating and label it records.",
),
allow(
Kind::Carried,
UI_SCAN,
"pull_sidecars",
"scan::apply_judgement",
"The sidecar pull: the judgement a sidecar records, written there by the \
photographer on another device.",
),
allow(
Kind::Carried,
UI_XMP,
"record_of",
"Xmp::set_colour",
"Mirrors the catalog's label into the XMP record written beside the original.",
),
allow(
Kind::Carried,
UI_XMP,
"take_in",
"xmp_sync::apply",
"The automatic XMP pull: another application's rating and label, the catalog \
winning where both hold one and the disagreement kept for a person.",
),
allow(
Kind::Carried,
UI_XMP,
"reload",
"xmp_sync::apply",
"The reload a person asks for in Settings: the file's values over the catalog's.",
),
// ---- not a verdict ----
allow(
Kind::NotAVerdict,
UI_DUPLICATES,
"strip",
"field .rating",
"Zeroes a clone so two copies' edits compare without their judgements; never \
stored.",
),
allow(
Kind::NotAVerdict,
UI_DUPLICATES,
"strip",
"field .flag",
"As the rating: a scratch clone.",
),
allow(
Kind::NotAVerdict,
UI_DUPLICATES,
"strip",
"field .label",
"As the rating: a scratch clone.",
),
allow(
Kind::NotAVerdict,
UI_DEVELOP,
"wire_peaking > |on_colour_picked|",
"Xmp::set_colour",
"Slint's Peaking.set_colour -- the focus-peaking overlay's colour, which only \
shares the name.",
),
allow(
Kind::NotAVerdict,
UI_DEVELOP,
"wire_peaking",
"Xmp::set_colour",
"Slint's Peaking.set_colour at start-up -- the overlay's colour.",
),
allow(
Kind::NotAVerdict,
UI_JUDGE,
"sync_ratings",
"field .rating",
"The grid cell's copy of the catalog's stars, repainted after a write.",
),
allow(
Kind::NotAVerdict,
UI_JUDGE,
"sync_ratings",
"field .flag",
"The grid cell's copy of the catalog's flag.",
),
allow(
Kind::NotAVerdict,
UI_JUDGE,
"sync_ratings",
"field .label",
"The grid cell's copy of the catalog's label.",
),
allow(
Kind::NotAVerdict,
UI_FILTER,
"wire_filter_ratings_and_people > |on_library_filter_unjudged_changed|",
"field .flag",
"The library filter's flag term.",
),
allow(
Kind::NotAVerdict,
UI_FILTER,
"wire_filter_ratings_and_people > |on_library_filter_flag_changed|",
"field .flag",
"The library filter's flag term.",
),
allow(
Kind::NotAVerdict,
UI_FILTER,
"wire_filter_ratings_and_people > |on_library_filter_label_changed|",
"field .label",
"The library filter's label term.",
),
];
const CATALOG_RATING: &str = "core/dr-catalog/src/rating.rs";
const CATALOG_TRASH: &str = "core/dr-catalog/src/trash.rs";
const CATALOG_DUPLICATES: &str = "core/dr-catalog/src/duplicates.rs";
const SIDECAR: &str = "core/dr-pipeline/src/sidecar.rs";
const XMP: &str = "core/dr-xmp/src/lib.rs";
const XMP_READ: &str = "core/dr-xmp/src/read.rs";
const UI_DEVELOP: &str = "ui/dr-ui/src/develop_ui.rs";
const UI_JUDGE: &str = "ui/dr-ui/src/library_ui/ratings_keywords.rs";
const UI_FILTER: &str = "ui/dr-ui/src/library_ui/filter_bar.rs";
const UI_DUPLICATES: &str = "ui/dr-ui/src/duplicates.rs";
const UI_DUPLICATES_SCREEN: &str = "ui/dr-ui/src/duplicates_ui.rs";
const UI_SCAN: &str = "ui/dr-ui/src/library/scan.rs";
const UI_SIDECAR: &str = "ui/dr-ui/src/library/sidecar.rs";
const UI_XMP: &str = "ui/dr-ui/src/xmp_sync.rs";
const UI_TRASH: &str = "ui/dr-ui/src/trash.rs";
const UI_COLL_TRASH: &str = "ui/dr-ui/src/collections_ui/trash.rs";
const UI_COLL_GRID: &str = "ui/dr-ui/src/collections_ui/wiring_grid.rs";
const fn allow(
kind: Kind,
file: &'static str,
within: &'static str,
writes: &'static str,
why: &'static str,
) -> Allowed {
Allowed {
file,
within,
writes,
kind,
why,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn repo_root() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
// ---- fixtures ----------------------------------------------------------
const RATING: &str = "core/dr-catalog/src/rating.rs";
const PRIMS: &[(&str, &str, &str)] = &[(RATING, "rating", "set_flag")];
/// A catalog with one writer, and a dispatch that calls it from a key.
fn tree(extra: &[(&str, &str)]) -> Vec<(String, String)> {
let mut files = vec![
(
RATING.to_string(),
r#"
pub fn set_flag(conn: &Connection, image: ImageId, flag: u8) {
conn.execute("UPDATE versions SET flag = ?2 WHERE id = ?1", (image, flag));
}
#[cfg(test)]
mod tests {
fn helper() { super::set_flag(c, i, 2); }
}
"#
.to_string(),
),
(
"ui/judge.rs".to_string(),
r#"
fn apply_judgement(ids: &[ImageId], flag: u8) {
for id in ids { dr_catalog::rating::set_flag(conn, *id, flag); }
}
pub fn wire(window: &AppWindow) {
window.global::<Library>().on_library_judged(move |f| {
apply_judgement(&chosen, f as u8);
});
}
"#
.to_string(),
),
];
files.extend(extra.iter().map(|(p, t)| (p.to_string(), t.to_string())));
files
}
const LIST: &[Allowed] = &[
allow(
Kind::Relay,
RATING,
"set_flag",
"sql versions",
"writes what it is handed",
),
allow(
Kind::Relay,
"ui/judge.rs",
"apply_judgement",
"rating::set_flag",
"dispatch",
),
allow(
Kind::Input,
"ui/judge.rs",
"wire > |on_library_judged|",
"judge::apply_judgement",
"a key",
),
];
fn problems(extra: &[(&str, &str)], list: &[Allowed]) -> Vec<String> {
check_with(&tree(extra), PRIMS, list).problems
}
#[test]
fn the_fixture_passes_as_listed() {
let report = check_with(&tree(&[]), PRIMS, LIST);
assert_eq!(report.problems, Vec::<String>::new());
// The test module's call is not a site.
assert_eq!(report.sites.len(), 3, "{:#?}", report.sites);
}
/// The failure the clause exists for: a signal that writes.
#[test]
fn an_evidence_producer_that_writes_a_flag_fails() {
let eyes = r#"
pub fn record_eyes(conn: &Connection, image: ImageId, shut: bool) {
if shut { dr_catalog::rating::set_flag(conn, image, 2); }
}
"#;
let p = problems(&[("core/dr-face/src/eyes.rs", eyes)], LIST);
assert_eq!(p.len(), 1, "{p:#?}");
assert!(
p[0].starts_with("core/dr-face/src/eyes.rs:3: `record_eyes`"),
"{}",
p[0]
);
}
/// Going round the catalog function does not go round the check.
#[test]
fn sql_that_writes_a_verdict_is_a_writer_wherever_it_is() {
let focus = r#"
fn peaking_pass(conn: &Connection, db: &str) {
conn.execute(&format!("UPDATE {db}.versions SET label = 1 WHERE id = ?1"), []);
}
"#;
let p = problems(&[("ui/peaking.rs", focus)], LIST);
assert_eq!(p.len(), 1, "{p:#?}");
assert!(
p[0].contains("`peaking_pass`") && p[0].contains("label"),
"{}",
p[0]
);
}
/// A relay's callers are checked: the dispatch called from a burst pass
/// is as much a write as the setter called there.
#[test]
fn a_relay_called_from_anywhere_but_its_listed_callers_fails() {
let bursts = r#"
fn choose_representative(ids: &[ImageId]) {
super::judge::apply_judgement(&ids[1..], 2);
}
"#;
let p = problems(&[("ui/bursts.rs", bursts)], LIST);
assert_eq!(p.len(), 1, "{p:#?}");
assert!(p[0].contains("`choose_representative`"), "{}", p[0]);
}
#[test]
fn a_renamed_import_is_followed() {
let dup = r#"
use dr_catalog::rating::{self as r};
use dr_catalog::rating::set_flag as reject;
fn by_module() { r::set_flag(c, i, 2); }
fn by_name() { reject(c, i); }
"#;
let p = problems(&[("ui/dups.rs", dup)], LIST);
assert_eq!(p.len(), 2, "{p:#?}");
}
#[test]
fn an_input_entry_must_sit_in_a_callback() {
let mut list = LIST.to_vec();
list.push(allow(
Kind::Input,
"ui/auto.rs",
"on_timer_elapsed_but_not_a_closure",
"judge::apply_judgement",
"a timeout is not a press",
));
let auto = r#"
fn on_timer_elapsed_but_not_a_closure() { apply_judgement(&ids, 2); }
use crate::judge::apply_judgement;
"#;
let p = problems(&[("ui/auto.rs", auto)], &list);
assert_eq!(p.len(), 1, "{p:#?}");
assert!(p[0].contains("not inside an `on_*` callback"), "{}", p[0]);
}
#[test]
fn a_stale_entry_fails() {
let mut list = LIST.to_vec();
list.push(allow(
Kind::Input,
"ui/gone.rs",
"wire > |on_gone|",
"rating::set_flag",
".",
));
let p = problems(&[], &list);
assert_eq!(p.len(), 1, "{p:#?}");
assert!(p[0].contains("no such write exists"), "{}", p[0]);
}
#[test]
fn a_field_named_as_a_verdict_is_a_site() {
let sidecar = "fn stamp(v: &mut Version) { v.rating = 5; }";
let p = problems(&[("core/dr-pipeline/src/x.rs", sidecar)], LIST);
assert_eq!(p.len(), 1, "{p:#?}");
assert!(p[0].contains("field .rating"), "{}", p[0]);
}
#[test]
fn the_sql_reader_finds_assignments_and_only_those() {
let w = |sql: &str| sql_writes(sql);
let cols =
|t: &str, c: &[&str]| vec![(t.to_string(), c.iter().map(|s| s.to_string()).collect())];
assert_eq!(
w("UPDATE versions SET rating = ?2 WHERE id = ?1"),
cols("versions", &["rating"])
);
assert_eq!(
w(
"UPDATE versions SET rating = CASE WHEN ?2 > 0 THEN ?2 ELSE rating END,
flag = CASE WHEN flag = ?3 THEN 0 ELSE flag END WHERE id = ?1 AND rating >= 1"
),
cols("versions", &["rating", "flag"])
);
assert_eq!(
w("update main.versions set label=1"),
cols("versions", &["label"])
);
// Minting an unjudged row is not a verdict; a judged one is.
assert!(
w("INSERT INTO versions(image_id, uuid, rating, flag) VALUES (?1, ?2, 0, 0)")
.is_empty()
);
assert_eq!(
w("INSERT INTO versions(image_id, rating, flag) VALUES (?1, 0, ?2)"),
cols("versions", &["flag"])
);
assert_eq!(
w("INSERT INTO versions(image_id, label) SELECT id, 3 FROM images"),
cols("versions", &["label"])
);
assert_eq!(
w("INSERT INTO versions SELECT * FROM other.versions"),
cols("versions", &["rating", "flag", "label"])
);
assert_eq!(
w("INSERT INTO versions(id, uuid) VALUES (?1, ?2)
ON CONFLICT(uuid) DO UPDATE SET flag = excluded.flag"),
cols("versions", &["flag"])
);
assert_eq!(
w("UPDATE images SET trashed_at = ?3 WHERE id = ?1"),
cols("images", &["trashed_at"])
);
// Reads, other tables and other columns are not writes.
assert!(w("SELECT rating, flag FROM versions WHERE flag = 2").is_empty());
assert!(w("UPDATE versions SET uuid = ?2 WHERE rating = 5").is_empty());
assert!(w("UPDATE images SET source_ref = ?2 WHERE trashed_at = 1").is_empty());
assert!(w("INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')").is_empty());
assert!(w("-- UPDATE versions SET flag = 2\nSELECT 1").is_empty());
}
/// The gate: the real tree, against the real list.
#[test]
fn no_verdict_is_written_but_by_a_person() {
let files = sources(&repo_root());
assert!(
files.len() > 100,
"read {} files — the wrong tree",
files.len()
);
let report = check(&files, ALLOWED);
assert!(
report.problems.is_empty(),
"FR-CULL-13:\n {}",
report.problems.join("\n ")
);
}
}