FR-DEV-3a is the property the declarative pipeline rests on: a node in `ops/` is one file because nothing in `ui/` has to learn about it. It was true — all fifteen ids grepped across `ui/` yield one hit, a localisation test — and it was held by discipline alone. That is the wrong mechanism for it. The failure is silent and cumulative: special-casing one operation to fix a layout problem is defensible on its own, and by the fifth the panel names half the chain and "a new operation is one file" has stopped being true without any single commit having broken it. Nothing would have told us. The ids are read from `ops/*.yaml` rather than listed, so a node added tomorrow is covered without anyone remembering this file — the same reason `traceability` parses its denominators from `requirements.md` at run time. Two decisions worth recording, because both are the difference between a test that holds and one that gets deleted: **Only string literals count.** `texture`, `contrast` and `clarity` are also ordinary graphics and English terms, and `texture` appears throughout `dr-ui` meaning a GPU texture. Matching bare words would fail constantly for reasons unrelated to the invariant. **`#[cfg(test)]` items are exempt, and finding them needs more care than it looks.** The first version cut each file at the first textual match of `#[cfg(test)]`, which in `develop.rs` is a *doc comment discussing the attribute* at line 969 — it read 18% of the most important file in the scan and passed. It now matches the attribute only as a whole line and skips the item by brace depth, and `MIN_SHIPPING_FRACTION` fails the test outright if the scan ever swallows the file again. The dangerous failure here is not a false alarm, which someone investigates; it is examining nothing and reporting success. Verified both ways: it passes on the tree, and an `"exposure"` planted at develop.rs:3635 — past two `#[cfg(test)]` attributes, exactly where the first version was blind — fails with the file, the line and the reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
272 lines
9.4 KiB
Rust
272 lines
9.4 KiB
Rust
// TRACES: FR-DEV-3a
|
|
//! The interface must not name an operation.
|
|
//!
|
|
//! This is the property the whole declarative pipeline rests on. A node in
|
|
//! `core/dr-pipeline/ops/` is one file precisely because nothing in `ui/` has
|
|
//! to learn about it: the panel builds controls from the parameter kinds the
|
|
//! core publishes and never asks which operation it is drawing (ARCH §4.3a,
|
|
//! FR-DEV-3a).
|
|
//!
|
|
//! Until now that was maintained by discipline alone, and the failure mode is
|
|
//! silent and cumulative. Special-casing one operation to fix a layout problem
|
|
//! is defensible on its own; by the fifth the panel names half the chain and
|
|
//! "a new operation is one file" has quietly stopped being true, without any
|
|
//! single commit having broken it.
|
|
//!
|
|
//! ## What counts as naming
|
|
//!
|
|
//! An operation id appearing as a **string literal** — `"exposure"`, in Rust
|
|
//! or in Slint. That is the form a special case takes, because the id is how
|
|
//! the panel would have to recognise the operation it wanted to treat
|
|
//! differently.
|
|
//!
|
|
//! Bare words are deliberately not matched. Three ids — `texture`, `contrast`
|
|
//! and `clarity` — are also ordinary graphics and English terms, and `texture`
|
|
//! in particular appears throughout `dr-ui` meaning a GPU texture. Matching
|
|
//! those would produce a test that fails constantly for reasons unrelated to
|
|
//! the invariant, which is a test people delete.
|
|
//!
|
|
//! ## What is exempt
|
|
//!
|
|
//! Code under `#[cfg(test)]`. The invariant is about the interface that ships;
|
|
//! a unit test asserting that `labels::resolve("clarity")` yields `"Clarity"`
|
|
//! is testing the localisation table, not special-casing an operation. There is
|
|
//! exactly one such case today, in `labels.rs`.
|
|
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Below this, assume the scan broke rather than that the code is clean.
|
|
///
|
|
/// The dangerous failure of a test like this is not a false alarm, which
|
|
/// someone investigates — it is silently examining nothing and reporting
|
|
/// success. An early version cut each file at the first *textual* match of
|
|
/// `#[cfg(test)]`, which in `develop.rs` is a doc comment discussing the
|
|
/// attribute: it read 18% of the most important file in the scan and passed.
|
|
///
|
|
/// `dr-ui` is currently 75% shipping code by line. The floor is set well under
|
|
/// that so ordinary movement does not trip it, and well over the number a
|
|
/// parsing bug produces.
|
|
const MIN_SHIPPING_FRACTION: f64 = 0.60;
|
|
|
|
/// Every operation id the pipeline declares.
|
|
///
|
|
/// Read from the declarations rather than listed here, so a node added
|
|
/// tomorrow is covered without anyone remembering to extend this file — the
|
|
/// same reason `traceability` parses its denominators from `requirements.md`
|
|
/// at run time.
|
|
fn operation_ids(ops_dir: &Path) -> Vec<String> {
|
|
let mut ids = Vec::new();
|
|
|
|
let entries =
|
|
fs::read_dir(ops_dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", ops_dir.display()));
|
|
|
|
for entry in entries {
|
|
let path = entry.expect("read dir entry").path();
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
|
continue;
|
|
}
|
|
// `_helpers.yaml` declares the shared WGSL library, not a node, and has
|
|
// no `id:`. The leading underscore is the documented marker.
|
|
if path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.is_some_and(|n| n.starts_with('_'))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let text = fs::read_to_string(&path)
|
|
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
|
|
|
|
let id = text
|
|
.lines()
|
|
.find_map(|l| l.strip_prefix("id:"))
|
|
.map(str::trim)
|
|
.unwrap_or_else(|| panic!("{} declares no id:", path.display()));
|
|
|
|
ids.push(id.to_string());
|
|
}
|
|
|
|
assert!(
|
|
!ids.is_empty(),
|
|
"found no operation declarations in {} — the path is wrong, and a scan \
|
|
over nothing passes for the wrong reason",
|
|
ops_dir.display()
|
|
);
|
|
|
|
ids
|
|
}
|
|
|
|
/// Every `.rs` and `.slint` file under a directory.
|
|
fn sources(dir: &Path, out: &mut Vec<PathBuf>) {
|
|
let entries =
|
|
fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()));
|
|
|
|
for entry in entries {
|
|
let path = entry.expect("read dir entry").path();
|
|
if path.is_dir() {
|
|
sources(&path, out);
|
|
} else if matches!(
|
|
path.extension().and_then(|e| e.to_str()),
|
|
Some("rs") | Some("slint")
|
|
) {
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Blank out string literals and line comments, so a brace inside either does
|
|
/// not move the depth count.
|
|
///
|
|
/// Raw strings and block comments are not handled. Neither appears around a
|
|
/// `#[cfg(test)]` item in this crate, and [`MIN_SHIPPING_FRACTION`] is the
|
|
/// backstop if that ever stops being true.
|
|
fn strip_literals_and_comments(line: &str) -> String {
|
|
let mut out = String::with_capacity(line.len());
|
|
let mut chars = line.chars().peekable();
|
|
let mut in_string = false;
|
|
|
|
while let Some(c) = chars.next() {
|
|
if in_string {
|
|
match c {
|
|
'\\' => {
|
|
chars.next();
|
|
}
|
|
'"' => in_string = false,
|
|
_ => {}
|
|
}
|
|
continue;
|
|
}
|
|
match c {
|
|
'"' => in_string = true,
|
|
'/' if chars.peek() == Some(&'/') => break,
|
|
_ => out.push(c),
|
|
}
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
/// The lines of a file that ship, each with its 1-based number.
|
|
///
|
|
/// For Rust, every `#[cfg(test)]` item is dropped. The attribute must be the
|
|
/// whole line — a doc comment *mentioning* it is prose, not an attribute, and
|
|
/// treating the two alike is the bug [`MIN_SHIPPING_FRACTION`] describes.
|
|
fn shipping_lines(path: &Path, text: &str) -> Vec<(usize, String)> {
|
|
let lines: Vec<&str> = text.lines().collect();
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
|
return lines
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, l)| (i + 1, (*l).to_string()))
|
|
.collect();
|
|
}
|
|
|
|
let mut out = Vec::new();
|
|
let mut i = 0;
|
|
|
|
while i < lines.len() {
|
|
if lines[i].trim() != "#[cfg(test)]" {
|
|
out.push((i + 1, lines[i].to_string()));
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
// Skip the item the attribute applies to: through its brace block, or
|
|
// to the `;` if it has none — `#[cfg(test)] use ...;` is legal.
|
|
let mut j = i + 1;
|
|
let mut depth: i32 = 0;
|
|
let mut opened = false;
|
|
|
|
while j < lines.len() {
|
|
let stripped = strip_literals_and_comments(lines[j]);
|
|
depth += stripped.matches('{').count() as i32;
|
|
depth -= stripped.matches('}').count() as i32;
|
|
|
|
if stripped.contains('{') {
|
|
opened = true;
|
|
}
|
|
if opened && depth <= 0 {
|
|
break;
|
|
}
|
|
if !opened && stripped.contains(';') {
|
|
break;
|
|
}
|
|
j += 1;
|
|
}
|
|
|
|
i = j + 1;
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn the_interface_names_no_operation() {
|
|
let crate_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
let repo = crate_root
|
|
.parent()
|
|
.and_then(Path::parent)
|
|
.expect("ui/dr-ui has a grandparent");
|
|
|
|
let ids = operation_ids(&repo.join("core/dr-pipeline/ops"));
|
|
|
|
let mut files = Vec::new();
|
|
sources(&crate_root.join("src"), &mut files);
|
|
sources(&crate_root.join("ui"), &mut files);
|
|
|
|
let mut offences = Vec::new();
|
|
let mut total = 0usize;
|
|
let mut scanned = 0usize;
|
|
|
|
for path in &files {
|
|
let text = fs::read_to_string(path)
|
|
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
|
|
|
|
total += text.lines().count();
|
|
let shipping = shipping_lines(path, &text);
|
|
scanned += shipping.len();
|
|
|
|
for (n, line) in shipping {
|
|
for id in &ids {
|
|
if line.contains(&format!("\"{id}\"")) {
|
|
offences.push(format!(
|
|
" {}:{n} names \"{id}\"\n {}",
|
|
path.strip_prefix(repo).unwrap_or(path).display(),
|
|
line.trim()
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let fraction = scanned as f64 / total as f64;
|
|
assert!(
|
|
fraction >= MIN_SHIPPING_FRACTION,
|
|
"only {scanned} of {total} lines ({:.0}%) were scanned, below the {:.0}% \
|
|
floor — `shipping_lines` is dropping code it should be reading, and \
|
|
this test is passing without having looked. Fix the scan before \
|
|
trusting the result.",
|
|
fraction * 100.0,
|
|
MIN_SHIPPING_FRACTION * 100.0
|
|
);
|
|
|
|
assert!(
|
|
offences.is_empty(),
|
|
"\n\nFR-DEV-3a: the interface must not name an operation.\n\n{}\n\n\
|
|
The core declares capabilities and the frontend composes them, which is \
|
|
why adding a develop node is one file in core/dr-pipeline/ops/ and \
|
|
touches no UI. A special case here takes that away from every node \
|
|
added afterwards.\n\n\
|
|
If a node needs presentation the panel cannot currently give it, the \
|
|
answer is a `presentation:` hint in its declaration and a `WidgetKind` \
|
|
the panel implements for every node that asks — see \
|
|
core/dr-pipeline/ops/README.md and `develop::supported`.\n\n\
|
|
Test code is exempt; this scans only what ships.\n",
|
|
offences.join("\n")
|
|
);
|
|
}
|