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
74 changes: 74 additions & 0 deletions .claude/skills/ci-gates/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12029,3 +12029,77 @@ renaming a required check. A reader who matches file name to subject opens the w
file, finds a shape checker, and concludes the freshness gate does not run. That has
already happened, in this loop's own notes. Both files now say so in their first ten
lines, which costs nothing and is the whole fix available.

## 461. A count over a live backlog is a clock reading nobody wrote down

`tri issues numbers` printed `486` open issues. That is not a fact about this
repository; it is a fact about the moment it was asked. Read as of a date one month
back, the same query answers **140** -- a 3.5x move in 33 days, and nothing in the old
output says which month it belonged to.

`--as-of YYYY-MM-DD` fixes the population instead of the phrasing. It drops `--state
open` -- an issue open THEN may be closed NOW, and that filter removes exactly the rows
that make the two readings differ -- reads `--state all` with `createdAt`/`closedAt`,
and keeps what was open at the end of that UTC day:

```
open_at(created, closed, t) = created <= t && (closed is empty || closed > t)
```

**The two boundaries point opposite ways** and that is the whole rule: created AT the
instant counts as existing, closed AT the instant counts as closed, so an issue opened
and closed in the same second is not open. A row with no creation time is not counted
rather than defaulted to open -- guessing would put it in the population in silence,
which is the failure the command exists to expose.

**The END of the day, not the start.** GitHub's own search reads a bare date in
`created:<=2026-08-01` as covering that whole day. Two tools answering the same question
must mean the same thing by the same date, or the second reader gets a different number
and blames the first.

**Three independent routes agree on 140:** GitHub search as two queries
(`created:<=T state:open` 43, plus `created:<=T closed:>T` 97); a full walk of all 1482
issues computing open-at-T from timestamps; and this command. The first two were run by
separate readers before the command existed.

A malformed date is **refused, not defaulted**. `--as-of 2026-8-1` errors out, because a
date the tool cannot read silently becoming "today" would print a number over the wrong
population under a heading that says the reading is anchored -- worse than no anchor at
all. The shape check is `skillnum::is_iso_date`, the rule already mutation-proved for
&sect;459's recovered anchors, not a second copy of the same ten conjuncts.

Without the flag the command now says so in its own first line: *this reading is NOT
anchored*. The default still answers, because refusing to count today's backlog would
be a different tool -- but it no longer lets the number pass as a fact.

## 462. `--limit 500` against 486 open issues: fourteen from printing a page as a census

Every `gh`-backed count in this CLI asked for at most `--limit` rows and then printed
what came back as a total. `gh` returns at most that many and says nothing about what it
left behind, so **a full page is a lower bound and only a short page is a total**.

Measured `2026-09-03T16:35Z`: **486** open issues against a default `--limit` of
**500**. Fourteen issues away from every figure this command prints becoming a page, in
silence, with no line of output different.

The check is one comparison and the boundary is its whole content:

```rust
pub fn read_is_complete(returned: usize, limit: usize) -> bool { returned < limit }
```

At exactly `limit` rows there may or may not be more, and the honest answer is that this
cannot tell -- so it reports incomplete. Mutating `<` to `<=` kills a test; the fixture
is `(486, 500)` and `(500, 500)`, the live boundary rather than an invented one.

**The class was four call sites, not one.** Grepping `"--limit"` across `cli/tri/src/`
found `numbers`, `dated`, `stale` and `gates prs` -- and `gates prs` carries a
**hardcoded 50** with no flag at all (10 open PRs at the time of measuring, so it does
not bite, and when it does it will bite in silence). Fixing only the command that
prompted the reading would have left three. Each was then run at its own boundary:
`--limit 486` prints the LOWER BOUND line, `--limit 487` prints COMPLETE.

This is &sect;457 one level down. There the population was a query and the figure went
stale; here the population is a query **and the tool does not know whether it saw all of
it**. An anchor on an incomplete read is worse than none: it says *this number can be
taken again* about a number that was never the whole thing.
11 changes: 11 additions & 0 deletions cli/tri/src/gates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1535,6 +1535,10 @@ fn prs(repo: Option<&str>) -> Result<()> {
"--state",
"open",
"--limit",
// A hard 50 with no flag: 10 open at 2026-09-03T16:35Z, so it does not
// bite -- and when it does it will bite in silence unless the read says
// whether it reached the end. The check below is why the constant can
// stay a constant.
"50",
"--json",
"number,title,mergeable",
Expand All @@ -1561,6 +1565,13 @@ fn prs(repo: Option<&str>) -> Result<()> {
return Ok(());
}

if !crate::issues::read_is_complete(items.len(), 50) {
println!(
"*** {} open PRs came back and the query asked for 50: this is a LOWER \
BOUND, not the open set. ***",
items.len()
);
}
println!(
"{:<7} {:<13} {:>7} {}",
"pr", "mergeable", "checks", "title"
Expand Down
226 changes: 212 additions & 14 deletions cli/tri/src/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,17 @@ pub enum IssuesCmd {
/// Print a systematic sample of this size. 0 prints only the population.
#[arg(long, default_value_t = 0)]
sample: usize,
/// How many open issues to read.
/// How many issues to read. The read is a LOWER BOUND when this many
/// come back -- the output says so rather than presenting a page as a
/// total.
#[arg(long, default_value_t = 500)]
limit: usize,
/// Count the backlog as it stood at the END of this UTC day
/// (`YYYY-MM-DD`), instead of now. Without it the population is a
/// query whose answer changes on every open and close, and the number
/// cannot be re-taken by a second reader.
#[arg(long)]
as_of: Option<String>,
},
/// Open issues whose figure is ANCHORED, so re-measuring it proves nothing.
Dated {
Expand Down Expand Up @@ -407,24 +415,89 @@ pub fn carries(title: &str) -> Carries {
}
}

/// Was this issue open at `instant`?
///
/// Both timestamps come from GitHub as ISO-8601 with a `Z` suffix, and such strings
/// compare lexicographically in chronological order -- so this needs no date library
/// and cannot drift from one. An issue with no `closedAt` is open now and was open
/// then, provided it existed.
///
/// The empty-`created` case returns false rather than defaulting to open: a row whose
/// creation time did not arrive is a row this cannot classify, and guessing would put
/// it in the population silently.
pub fn open_at(created: &str, closed: &str, instant: &str) -> bool {
if created.is_empty() || created > instant {
return false;
}
closed.is_empty() || closed > instant
}

/// `YYYY-MM-DD` to the last instant of that UTC day.
///
/// The END of the day, not the start, because that is what GitHub's own search means
/// by `created:<=2026-08-01` -- a bare date there covers the whole day. Two tools
/// answering the same question must mean the same thing by the same date, or the
/// second reader gets a different number and blames the first.
///
/// The shape check is `skillnum::is_iso_date`, the rule already mutation-proved for
/// the skill anchors, rather than a second copy of the same ten conjuncts here.
pub fn instant_of(date: &str) -> Result<String> {
let c: Vec<char> = date.chars().collect();
if c.len() != 10 || !crate::skillnum::is_iso_date(&c) {
anyhow::bail!(
"--as-of wants YYYY-MM-DD and got `{date}`. A date this cannot read is \
refused rather than silently treated as today, which would print a \
number over the wrong population under an anchor that looks right."
);
}
Ok(format!("{date}T23:59:59Z"))
}

/// Did the read reach the end, or did it fill the page?
///
/// `gh` returns at most `--limit` rows and says nothing about what it left behind, so
/// a FULL page is a lower bound and anything short of one is complete. The boundary is
/// the whole content of this function: at exactly `limit` rows there may or may not be
/// more, and the honest answer is that this cannot tell -- so it reports incomplete.
pub fn read_is_complete(returned: usize, limit: usize) -> bool {
returned < limit
}

/// The population of re-measurable open issues, and a reproducible sample of it.
///
/// A rate is only worth taking if the same sample can be taken again, so the
/// sample is SYSTEMATIC -- every k-th issue by ascending number -- rather than
/// chosen. Nothing here is random: run it next month and the overlap is exact
/// wherever the backlog has not moved.
fn numbers(sample: usize, limit: usize, single: bool) -> Result<()> {
fn numbers(sample: usize, limit: usize, single: bool, as_of: Option<&str>) -> Result<()> {
let lim = limit.to_string();
let raw = gh(&[
"issue",
"list",
"--state",
"open",
"--limit",
&lim,
"--json",
"number,title",
])?;
let instant = as_of.map(instant_of).transpose()?;
// With --as-of the state filter has to come off: an issue open THEN may be
// closed NOW, and `--state open` would drop exactly the ones that make the two
// readings differ. The filtering is done here from the timestamps instead.
let raw = if instant.is_some() {
gh(&[
"issue",
"list",
"--state",
"all",
"--limit",
&lim,
"--json",
"number,title,createdAt,closedAt",
])?
} else {
gh(&[
"issue",
"list",
"--state",
"open",
"--limit",
&lim,
"--json",
"number,title",
])?
};
let v: serde_json::Value = serde_json::from_str(&raw).context("gh returned no JSON")?;
let arr = v.as_array().cloned().unwrap_or_default();
if arr.is_empty() {
Expand All @@ -433,8 +506,22 @@ fn numbers(sample: usize, limit: usize, single: bool) -> Result<()> {
did not run print the same zero."
);
}
// Whether the READ is complete is a different question from what it contains,
// and it has to be asked before any total is printed. `gh` returns at most
// --limit rows and says nothing about what it left behind, so a full page is a
// LOWER BOUND. Measured 2026-09-03: 486 open against a default limit of 500 --
// fourteen issues from printing a page as a census, in silence.
let complete = read_is_complete(arr.len(), limit);
let mut rows: Vec<(u64, String, Carries)> = arr
.iter()
.filter(|i| match instant.as_deref() {
None => true,
Some(t) => open_at(
i["createdAt"].as_str().unwrap_or(""),
i["closedAt"].as_str().unwrap_or(""),
t,
),
})
.map(|i| {
let n = i["number"].as_u64().unwrap_or(0);
let t = i["title"].as_str().unwrap_or("").to_string();
Expand All @@ -450,7 +537,25 @@ fn numbers(sample: usize, limit: usize, single: bool) -> Result<()> {
.filter(|r| matches!(r.2, Carries::Digits | Carries::Words | Carries::Both))
.collect();

println!("OPEN ISSUES THAT STATE A COUNT IN THE TITLE\n");
match instant.as_deref() {
Some(t) => println!(
"OPEN ISSUES THAT STATE A COUNT IN THE TITLE, AS OF {t}\n\n \
This reading is ANCHORED: the population is the set of issues created\n \
at or before {t} and not closed by then, which does not move. Run it\n \
again next month and every number below is the same.\n"
),
None => println!(
"OPEN ISSUES THAT STATE A COUNT IN THE TITLE\n\n \
This reading is NOT anchored: `open issues` is a query, not a set, and\n \
its answer changes on every open and close. Pass --as-of YYYY-MM-DD to\n \
take a number a second reader can take again.\n"
),
}
if complete {
println!(" issues read from gh {} (fewer than the --limit of {limit}, so the read is COMPLETE)", arr.len());
} else {
println!(" issues read from gh {} *** EQUALS the --limit of {limit}: this is a LOWER BOUND, not a total. Raise --limit and read again. ***", arr.len());
}
println!(" open issues read {}", rows.len());
println!(" count in digits only {}", c(Carries::Digits));
println!(" count in words only {}", c(Carries::Words));
Expand Down Expand Up @@ -569,7 +674,8 @@ pub fn run(cmd: &IssuesCmd) -> Result<()> {
sample,
limit,
single,
} => return numbers(*sample, *limit, *single),
as_of,
} => return numbers(*sample, *limit, *single, as_of.as_deref()),
IssuesCmd::Dated { limit, list } => return dated(*limit, *list),
IssuesCmd::Stale { limit } => limit,
};
Expand Down Expand Up @@ -641,6 +747,9 @@ pub fn run(cmd: &IssuesCmd) -> Result<()> {
}

println!("OPEN ISSUES THAT CALL A WORKFLOW RED, AND WHAT IT DOES ON MASTER TODAY\n");
if !read_is_complete(issues.len(), *limit) {
println!(" issues read from gh {} *** EQUALS the --limit of {limit}: a LOWER BOUND, not a total. Raise --limit and read again. ***", issues.len());
}
println!(" open issues read {}", issues.len());
println!(" workflow files {}", files.len());
println!(" titles claiming red {}", rows.len());
Expand Down Expand Up @@ -1066,6 +1175,9 @@ fn dated(limit: usize, list: bool) -> Result<()> {
let anchored = pop.len() - c(Anchor::Free);

println!("OPEN ISSUES WHOSE FIGURE RE-MEASUREMENT CANNOT JUDGE\n");
if !read_is_complete(issues.len(), limit) {
println!(" issues read from gh {} *** EQUALS the --limit of {limit}: a LOWER BOUND, not a total. Raise --limit and read again. ***", issues.len());
}
println!(" open issues read {}", issues.len());
println!(" no figure in the title {no_figure}");
println!(" POPULATION (carries a figure) {}", pop.len());
Expand Down Expand Up @@ -1243,3 +1355,89 @@ mod single_digit_tests {
}
}
}

#[cfg(test)]
mod as_of_tests {
use super::{instant_of, open_at, read_is_complete};

const T: &str = "2026-08-01T23:59:59Z";

/// The probe: the two shapes that ARE open at the instant.
#[test]
fn an_issue_is_open_then_if_it_existed_and_had_not_closed() {
assert!(open_at("2026-07-01T10:00:00Z", "", T), "still open today");
assert!(
open_at("2026-07-01T10:00:00Z", "2026-09-01T10:00:00Z", T),
"closed later, so it was open then"
);
}

/// The counter-examples, and the second is the whole reason `--state open` cannot
/// be left on the query: an issue closed before the instant is open NOW-negative
/// and then-negative, but one closed AFTER it is open then and closed now.
#[test]
fn it_is_not_open_then_if_it_did_not_exist_or_had_closed() {
assert!(!open_at("2026-09-01T10:00:00Z", "", T), "created after");
assert!(
!open_at("2026-07-01T10:00:00Z", "2026-07-15T10:00:00Z", T),
"closed before"
);
}

/// Both boundaries are inclusive-of-existing and exclusive-of-surviving, and they
/// are opposite: created AT the instant counts as existing, closed AT the instant
/// counts as closed. An issue opened and closed in the same second is not open.
#[test]
fn the_two_boundaries_point_opposite_ways() {
assert!(open_at(T, "", T), "created exactly at the instant existed");
assert!(!open_at(T, T, T), "closed exactly at the instant is closed");
}

/// A row this cannot classify does not get a default. Guessing "open" would put it
/// in the population in silence, which is the failure this whole command is about.
#[test]
fn a_row_with_no_creation_time_is_not_counted() {
assert!(!open_at("", "", T));
assert!(!open_at("", "2026-09-01T10:00:00Z", T));
}

#[test]
fn a_date_becomes_the_last_instant_of_that_utc_day() {
assert_eq!(instant_of("2026-08-01").unwrap(), "2026-08-01T23:59:59Z");
assert_eq!(instant_of("2099-12-31").unwrap(), "2099-12-31T23:59:59Z");
}

/// Refused, not defaulted. A date this cannot read must not become "today" under
/// a heading that says the reading is anchored.
#[test]
fn a_date_it_cannot_read_is_refused() {
for bad in [
"2026-8-1",
"01-08-2026",
"2026-08-01T00:00:00Z",
"",
"yesterday",
] {
assert!(instant_of(bad).is_err(), "{bad} must be refused");
}
}

/// The boundary IS the rule. Measured 2026-09-03: 486 open against a default limit
/// of 500, so this is fourteen issues from mattering.
#[test]
fn a_full_page_is_a_lower_bound_and_a_short_one_is_a_total() {
assert!(read_is_complete(486, 500), "short page: complete");
assert!(
!read_is_complete(500, 500),
"full page: cannot tell, so not complete"
);
assert!(
!read_is_complete(501, 500),
"over the limit is not complete either"
);
assert!(
read_is_complete(0, 1),
"an empty read of a non-zero limit is complete"
);
}
}
Loading
Loading