Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 121 additions & 3 deletions cli/tri/src/types_dup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ pub enum TypesCmd {
/// Print the fields of every definition, not just the conflicted ones.
#[arg(long)]
all: bool,
/// Print `<file>:<line>\t<Name>` for every struct definition counted,
/// and nothing else, so a second reader can subtract SETS.
///
/// `--all` prints a multi-line block per name and cannot be subtracted;
/// this prints one line per definition and can. Chosen over the other
/// censuses because `tri census audit` measured this exact number and
/// then REFUSED to add a row for it: a looser counter reads 1182 where
/// this reads 1180, and its note says "any counter accurate enough to
/// agree is a copy of its matcher". That is true of counters and false
/// of populations -- `comm` on two lists names the two rows in one
/// command, which is how the `struct = 21,` pair was found by hand.
#[arg(long)]
defs: bool,
},
/// Hold the conflicted set: a new conflict fails, and a resolved one fails
/// until it is blessed away.
Expand Down Expand Up @@ -903,10 +916,30 @@ fn redef_probe(root: &Path) -> Result<()> {
Ok(())
}

/// The counted definitions, one line each, in BYTE order -- `LC_ALL=C sort`
/// order, so `comm` takes the output directly.
///
/// Deliberately NOT shared with `jumps::site_lines`: two call sites over two
/// different tuples, and a shared formatter would have to be told which fields
/// make the identity. The rule that is worth repeating is the shape, not the
/// code.
fn def_lines(defs: &[(String, String, usize)]) -> Vec<String> {
let mut v: Vec<String> = defs
.iter()
.map(|(name, file, line)| format!("{file}:{line}\t{name}"))
.collect();
v.sort();
v
}

pub fn run(cmd: &TypesCmd) -> Result<()> {
let root = repo_root()?;
let mut defs_only = false;
let all = match cmd {
TypesCmd::Dup { all } => *all,
TypesCmd::Dup { all, defs } => {
defs_only = *defs;
*all
}
TypesCmd::Ratchet { bless } => {
let specs = read_specs(&root);
if specs.is_empty() {
Expand Down Expand Up @@ -962,13 +995,23 @@ pub fn run(cmd: &TypesCmd) -> Result<()> {
);
}
let mut by_name: BTreeMap<String, Vec<Def>> = BTreeMap::new();
let mut total = 0usize;
let mut counted: Vec<(String, String, usize)> = Vec::new();
for (f, src) in &specs {
for (n, d) in defs_in(f, src) {
total += 1;
counted.push((n.clone(), d.file.clone(), d.line));
by_name.entry(n).or_default().push(d);
}
}
// ONE enumeration behind the headline and the list: `total` used to be its
// own counter, and a counter cannot be subtracted from another reader's.
let def_lines = def_lines(&counted);
if defs_only {
for l in &def_lines {
println!("{l}");
}
return Ok(());
}
let total = def_lines.len();

let multi: Vec<(&String, &Vec<Def>)> = by_name.iter().filter(|(_, v)| v.len() > 1).collect();
let conflicted: Vec<_> = multi
Expand Down Expand Up @@ -1031,6 +1074,81 @@ mod tests {
defs_in("x.t27", src)
}

/// The three spellings the corpus uses, one newtype, and the row that made
/// the loose counter read 1182: `struct = 21,` is an ENUM MEMBER named
/// `struct`, which is not a definition. Written on its own line because
/// that is how the corpus writes it -- `specs/lsp/schema.t27:155` and
/// `:204`, which are the only two, and so are the whole 1182-vs-1180 gap.
const THREE_SPELLINGS: &str = "\
struct Alpha { a: u32 }
pub struct Beta { b: u32 }
const Gamma = struct { c: u32 };
struct Delta(str);
enum E {
struct = 21,
}
";

/// A SECOND reader of `tri types dup --defs`: count the LINES that open a
/// type. Written from the three spellings in the doc comment rather than
/// from `defs_in`, and deliberately as loose as the counter `tri census
/// audit` describes -- which is why it also counts the enum member.
fn defs_by_a_loose_second_reader(src: &str) -> usize {
src.lines()
.map(|l| l.trim())
.filter(|t| {
t.starts_with("struct ") || t.starts_with("pub struct ") || t.contains("= struct ")
})
.count()
}

#[test]
fn the_count_and_the_emitted_definitions_are_the_same_number() {
// THE POINT OF THE FLAG. The headline prints `def_lines.len()`; this
// asserts that number against a reader written independently of it,
// and then NAMES the row the two disagree about -- which is the thing
// `tri census audit` says a second COUNTER cannot do.
let counted: Vec<(String, String, usize)> = defs_in("x.t27", THREE_SPELLINGS)
.into_iter()
.map(|(n, d)| (n, d.file, d.line))
.collect();
let lines = def_lines(&counted);
assert_eq!(lines.len(), 4, "four definitions and one enum member");
assert_eq!(
defs_by_a_loose_second_reader(THREE_SPELLINGS),
5,
"the loose reader counts `struct = 21,` -- the 1182-vs-1180 shape"
);
// And the disagreement is LOCATABLE, not just a difference of two:
// the only line the census declined is the enum member's.
let census_lines: BTreeMap<usize, ()> = counted.iter().map(|(_, _, l)| (*l, ())).collect();
assert!(!census_lines.contains_key(&6), "line 6 is the enum member");
assert_eq!(census_lines.len(), lines.len(), "one line per definition");
}

#[test]
fn the_emitted_definitions_are_unique_and_sorted() {
let lines = def_lines(&[
("B".into(), "z.t27".into(), 1),
("A".into(), "a.t27".into(), 10),
("A".into(), "a.t27".into(), 2),
]);
let mut sorted = lines.clone();
sorted.sort();
assert_eq!(lines, sorted, "unsorted output cannot be `comm`ed");
assert_eq!(
lines
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len(),
lines.len()
);
// Byte order, not numeric: `a.t27:10` before `a.t27:2`. Stated because
// it looks like a bug and is the property `comm` requires.
assert_eq!(lines[0], "a.t27:10\tA");
assert_eq!(lines[1], "a.t27:2\tA");
}

/// W715: a `//` tail is not part of the type. `verdict()` decides
/// CONFLICTED vs DUPLICATED by comparing field lists, so a comment inside
/// a type makes two identical definitions look like a disagreement.
Expand Down
11 changes: 11 additions & 0 deletions docs/now/2026-09-04-a-total-that-agrees-can-hide-two-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# NOW -- A total that agrees can hide errors in both directions (2026-09-04)

## `tri types dup --defs` emits the identity of every definition it counted (Refs #3035)

- the census prints **1180** struct definitions and, until now, no way to say WHICH — so a second reader could compare totals and nothing else
- `--defs` prints one `<file>:<line>\t<name>` per counted definition, sorted, byte-identical across runs, so two readers subtract **identities** with `comm` instead of comparing numbers
- it earned itself immediately, on a defect a total can never show: `specs/demos/jones_topology_filter.t27:37` is `const dim = structure.len();` and the census records it as a definition named **`dim`**; line 90 is `const t = structure[i];`, recorded as **`t`**
- the rule is `after.trim().starts_with("struct")` at `types_dup.rs:186` — **no word boundary**, so `structure` reads as `struct`. The published figure is **1178**, not 1180, and `docs/TYPE_CONFLICTS.md`, the types ratchet and the UNCOVERED note in `census.rs` all quote it
- filed as **#3035** rather than patched here: the repair moves a number several consumers quote, so it wants its own change with those consumers updated in the same commit
- **not reproduced by me:** the report that motivated this described a 2-for-2 swap against a loose second reader with equal totals of 1180. My own second reader read **476** against 1180 — it asks a NARROWER question (only `struct Name`, and it includes `bootstrap/tests/fixtures/`), so it is a different population and not a second reading. The defect is confirmed directly instead, by reading the two lines the finding names
- the sibling `--sites` flag from the same pass is **NOT** shipped: it emits a source path with a GENERATED-Verilog line number, and 17 of 17 rows name a line that does not carry the construct
Loading