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
35 changes: 35 additions & 0 deletions .claude/skills/ci-gates/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9122,3 +9122,38 @@ That is the guard working, not a defect, and it is worth stating in a report
rather than quietly re-running later: **a tool that refuses is not the same as a
tool that fails**, and a summary that lists both as RED is one line away from
being wrong.

## 366. Two of my own commands, one population, two answers

`tri prose report` said **107 specs that do not parse**. `tri unparsed report`
said **76**. Both are mine, both walk the same corpus, and neither had any
reason to be believed over the other.

The gap: 21 files under `fixtures/`, broken ON PURPOSE as detector inputs, and
10 specs that parse and fail at a later stage. Exactly the two rules I added to
`report`, then to `locate` after finding they had not travelled -- and which
never reached the third sibling.

**Two implementations of one question is a control you get for free. Run both
and subtract.** The disagreement was visible in one command and I only saw it
because I ran them side by side for an unrelated reason.

## 367. Third occurrence means fix the class, not the case

The same lesson had already been written down twice. A third instance is not
another case; it is evidence the cure was wrong.

So the scope moved into ONE function -- `parse_failures` -- returning the
parse-stage failures and counts of what was set aside. Both commands call it.
Disagreement is now structurally impossible rather than tested for, which is the
difference between a fix and a rule.

## 368. The options list carried a stale claim

I opened this iteration on "the census abstains on ten, and `tri prose report`
answers six of them". It answers **zero**: earlier repairs closed those specs,
and the sentence had been true when written and never re-measured.

**An option list is a claim with a date on it.** Re-measure the premise before
spending an iteration on it -- the measurement took one command and would have
saved picking it at all.
38 changes: 17 additions & 21 deletions cli/tri/src/prose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
//! It never edits a line the compiler did not stop on, and it refuses outright
//! when the line it stops on looks like code. A spec whose real obstacle is an
//! unimplemented construct is reported as such and left alone.
use crate::unparsed::parse_failures;
use anyhow::Result;
use clap::Subcommand;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -197,32 +198,21 @@ pub fn run(cmd: &ProseCmd, root: PathBuf) -> Result<()> {
);
};

let out = std::process::Command::new("git")
.args(["ls-files", "*.t27"])
.current_dir(&root)
.output()?;
let specs: Vec<PathBuf> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| root.join(s))
.filter(|p| p.is_file())
.collect();
// ONE shared scope, so this command and `tri unparsed` cannot disagree
// about which specs a census may speak about. They did, and the gap was
// exactly the two rules each sibling had to learn on its own: this one
// reported "107 specs that do not parse" where `unparsed` reported 76 --
// 21 fixtures broken ON PURPOSE, and 10 specs that parse and fail later.
let scope = parse_failures(&root, &t27c);
let (fixtures, other_stage) = (scope.fixtures, scope.other_stage);

let mut prose: Vec<(PathBuf, usize, Vec<String>)> = Vec::new();
let mut code: Vec<(PathBuf, usize, String)> = Vec::new();
let mut other = 0usize;
let mut scanned = 0usize;

for spec in &specs {
let ok = std::process::Command::new(&t27c)
.arg("check")
.arg(spec)
.current_dir(&root)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
continue;
}
for (rel, _) in &scope.failures {
let spec = &root.join(rel);
scanned += 1;
let (fixed, outcome) = walk(&t27c, &root, spec, 200);
match outcome {
Expand All @@ -235,7 +225,13 @@ pub fn run(cmd: &ProseCmd, root: PathBuf) -> Result<()> {

let rel = |p: &Path| p.strip_prefix(&root).unwrap_or(p).display().to_string();

println!(" specs that do not parse {scanned}");
println!(" specs refused at PARSE {scanned}");
if other_stage > 0 {
println!(" ... refused at a LATER stage {other_stage} (they parse)");
}
if fixtures > 0 {
println!(" broken ON PURPOSE under fixtures/ {fixtures} (detector inputs, not debt)");
}
println!(" ... blocked ONLY by prose {}", prose.len());
println!(" ... blocked by code {}", code.len());
if other > 0 {
Expand Down
71 changes: 68 additions & 3 deletions cli/tri/src/unparsed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,70 @@ fn accepted_head(line: &str) -> bool {
/// A file under `fixtures/` is BROKEN ON PURPOSE -- the reference input for a
/// detector, not debt. `tools/specs_generate_baseline.txt` omits all of them; a
/// census that counts them disagrees with the repository's own ledger.
fn is_fixture(path: &str) -> bool {
/// The specs a census may speak about, with the two exclusions every command
/// here needs and each one had to learn separately.
///
/// `report` learned the stage split, `locate` did not and answered about type
/// errors, `prose` learned neither and reported 107 where its sibling reported
/// 76. Three commands, one rule, three separate discoveries -- so the rule now
/// lives in ONE function and disagreement is structurally impossible rather
/// than merely tested for.
///
/// Returns the parse-stage failures with the compiler's output, and counts of
/// what was set aside.
pub(crate) struct Scope {
pub failures: Vec<(String, String)>,
pub fixtures: usize,
pub other_stage: usize,
pub tracked: usize,
}

pub(crate) fn parse_failures(root: &Path, t27c: &Path) -> Scope {
let out = std::process::Command::new("git")
.args(["ls-files", "*.t27"])
.current_dir(root)
.output();
let list: Vec<String> = match out {
Ok(o) => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|s| s.to_string())
.filter(|s| root.join(s).is_file())
.collect(),
Err(_) => Vec::new(),
};
let mut sc = Scope {
failures: Vec::new(),
fixtures: 0,
other_stage: 0,
tracked: list.len(),
};
for spec in list {
let Ok(o) = std::process::Command::new(t27c)
.arg("check")
.arg(&spec)
.current_dir(root)
.output()
else {
continue;
};
if o.status.success() {
continue;
}
if is_fixture(&spec) {
sc.fixtures += 1;
continue;
}
let text = String::from_utf8_lossy(&o.stderr) + String::from_utf8_lossy(&o.stdout);
if stage_of(&text) != Stage::Parse {
sc.other_stage += 1;
continue;
}
sc.failures.push((spec, text.to_string()));
}
sc
}

pub(crate) fn is_fixture(path: &str) -> bool {
path.contains("/fixtures/")
}

Expand All @@ -461,15 +524,17 @@ fn is_fixture(path: &str) -> bool {
///
/// The discriminator is checked both ways: no typecheck output contains a
/// parse word, and no parse output contains "Typecheck".
/// Shared with `prose`, which counted every failing `.t27` as a parse failure
/// until this was lifted out: the same category error, in the third sibling.
#[derive(PartialEq, Clone, Copy)]
enum Stage {
pub(crate) enum Stage {
Lex,
Parse,
Typecheck,
Semantic,
}

fn stage_of(text: &str) -> Stage {
pub(crate) fn stage_of(text: &str) -> Stage {
if text.contains("Typecheck FAILED") {
Stage::Typecheck
} else if text.contains("unterminated string literal") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# NOW -- Two of my own commands disagreed about the same population (2026-08-30)

## Two of my own commands disagreed about the same population (Refs #2864)

- I opened this iteration to bridge tri unparsed and tri prose -- the census abstains on some specs and prose was supposed to answer six of them. It answers ZERO: the earlier prose repairs closed them, and my own options list carried a stale claim.
- What was there instead: prose report said '107 specs that do not parse' where unparsed report said 76. The gap is 21 fixtures, broken ON PURPOSE, and 10 specs that parse and fail at a later stage -- the same two rules I added to report, then to locate, and which never travelled to the third sibling.
- Third occurrence of one lesson, so the cure is structural: the scope now lives in ONE function, parse_failures, used by both. Disagreement is impossible rather than merely tested for. Both report 76 parse failures and 21 fixtures.
- No compiler change; FROZEN_HASH does not move. All gates green, corpus ratchet CLEAN.
Loading