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
58 changes: 58 additions & 0 deletions .claude/skills/ci-gates/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6397,3 +6397,61 @@ which was tested on purpose before the real one arrived.

**Write the ratchet before the work it will police, not after.** The one that
already exists is the one that reports the change you did not predict.

## 205. A verdict the tool did not earn

`tri types dup` calls a name CONFLICTED when its two definitions have different
field lists. Four names — `Agent`, `AgentStatus`, `Color`, `HealthStatus` — are
reported CONFLICTED because one side is written `variants : ,` (the corpus's
enum idiom) and the reader parses **zero** fields from it. Empty list versus
full list, therefore "they disagree."

Three of the four really are distinct types, so the verdict is right. It is
still not a measurement: the instrument was comparing nothing against
something, and it happened to land on the answer.

**A right answer produced by a broken instrument is an anecdote, not a result.**
When you find one, record the coincidence next to the verdict — otherwise the
next reader takes the tool's agreement as corroboration, and it is not.

## 206. `|---|---|` inside a regex is four alternations

Rebuilding a markdown table with `re.sub`, I wrote the separator row into the
pattern literally:

re.sub(r"(## DRIFT.*?\|---\|---\|---\|---\|\n)(?:\|.*\n)+", ...)

The pipes are escaped there. In the version I actually ran they were not, so
the pattern read as `## DRIFT.*?---` OR `---` OR `---` OR `---` OR `\n...`, and
the substitution deleted from the DRIFT heading to the end of the document —
three sections and a 34-row table, silently, with a success exit.

Caught only because a `grep -c "^| \`"` afterwards said 46 where it should have
said 80.

**Never regex a document you can regenerate.** The table came from JSON; the
fix was to rewrite the whole file from the data in one pass, which is both
shorter and has no partial-failure mode. Reach for a surgical edit when the
source of truth is the file itself — not when the file is already a rendering
of something else.

## 207. A classification is a reading, and readings go stale

Eighty conflicted type names, each opened and judged DRIFT or DISTINCT with the
evidence written down. That document is worth exactly as much as its agreement
with the tree, and nothing about it fails when the tree moves.

So the cross-check is a gate, and both directions are red:

classified but no longer conflicting -> STALE (a repair landed)
conflicting but not classified -> UNJUDGED (nobody has read it)

Only UNJUDGED feels like a failure. Passing over STALE is how a document turns
into decoration — it keeps describing work that is already done, and the reader
who trusts it acts on a tree that no longer exists.

The command found `HealthStatus` on its **first execution**: the eightieth
conflict, created hours earlier by teaching the field reader that `pub name: T`
is a field, in a run the classification predated.

**Any document that states a measurement needs a gate that re-takes it.**
24 changes: 24 additions & 0 deletions .github/workflows/corpus-ratchet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,30 @@ jobs:
fi
exit $rc

# The ratchet above holds the SET of conflicted names. It cannot tell
# whether anyone has READ them. docs/TYPE_CONFLICTS.md splits every one
# into DRIFT (one concept, two definitions) or DISTINCT (two concepts,
# one name) with the reading that decided it -- and a written reading of
# a tree is exactly the kind of claim that quietly stops being true.
#
# Both directions fail here, deliberately. An UNJUDGED name is a conflict
# nobody has looked at; a STALE row is a document describing a repair
# that already landed. Only one of those feels like a failure, and
# treating the other as a pass is how a document becomes decoration.
- name: Every conflicted type name has a written verdict
run: |
set -o pipefail
rc=0
./target/debug/tri types classified > /tmp/classified.log 2>&1 || rc=$?
cat /tmp/classified.log
if [ "$rc" != "0" ]; then
echo "::error::docs/reports/type_conflicts_classified.json disagrees with the tree."
echo "::error::UNJUDGED means a new conflict nobody has read; STALE means a row"
echo "::error::about a name that is no longer conflicting. Read it, then edit the"
echo "::error::json and the tables in docs/TYPE_CONFLICTS.md to match."
fi
exit $rc

- name: Run the corpus ratchet
id: ratchet
run: |
Expand Down
82 changes: 82 additions & 0 deletions cli/tri/src/types_dup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,23 @@ pub enum TypesCmd {
#[arg(long)]
bless: bool,
},
/// Cross-check the written classification against what the tree says today.
///
/// `docs/TYPE_CONFLICTS.md` splits every conflicted name into DRIFT (one
/// concept, two definitions) and DISTINCT (two concepts, one name). That
/// split is a READING, taken on a day, and readings go stale: a name gets
/// converged, a name gets added, a definition moves. This reports both
/// directions of the drift so the document cannot quietly describe a tree
/// that no longer exists.
Classified,
}

/// Where the conflicted set is pinned.
const LEDGER: &str = "docs/reports/type_conflicts.json";

/// Where each conflicted name's verdict and the reading behind it are written.
const CLASSIFICATION: &str = "docs/reports/type_conflicts_classified.json";

#[derive(serde::Serialize, serde::Deserialize, Default)]
struct Ledger {
/// What wrote it, so a reader knows which command to re-run.
Expand Down Expand Up @@ -350,6 +362,58 @@ pub fn verdict(defs: &[Def]) -> &'static str {
}
}

#[derive(serde::Deserialize)]
struct ClassifiedName {
name: String,
verdict: String,
}

#[derive(serde::Deserialize)]
struct Classification {
names: Vec<ClassifiedName>,
}

/// Report the classification against a live reading. Non-empty drift in either
/// direction exits non-zero: a stale row and an unjudged conflict are both a
/// document making a claim the tree does not support.
fn classified(root: &std::path::Path, observed: &[String]) -> Result<()> {
let path = root.join(CLASSIFICATION);
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("{} is missing -- see docs/TYPE_CONFLICTS.md", path.display()))?;
let c: Classification = serde_json::from_str(&raw)
.with_context(|| format!("{} is not readable as a classification", path.display()))?;

let names: Vec<String> = c.names.iter().map(|n| n.name.clone()).collect();
// `drift` is the same set difference in both directions the ratchet uses;
// one implementation, so the two commands cannot disagree about what a
// difference is.
let (unjudged, stale) = drift(&names, observed);

let d = c.names.iter().filter(|n| n.verdict == "DRIFT").count();
let x = c.names.iter().filter(|n| n.verdict == "DISTINCT").count();
println!(" classification: {} name(s) -- {d} DRIFT, {x} DISTINCT", names.len());
println!(" tree today: {} conflicted name(s)", observed.len());

for n in &stale {
println!(" STALE {n}: classified, but no longer conflicting -- drop the row");
}
for n in &unjudged {
println!(" UNJUDGED {n}: conflicting, but nothing has read it");
}

if stale.is_empty() && unjudged.is_empty() {
println!("\n OK: every conflicted name in the tree has a written verdict, and every");
println!(" written verdict is about a name that is still conflicting.");
return Ok(());
}
anyhow::bail!(
"{} stale row(s) and {} unjudged conflict(s). Re-read them and update {}.",
stale.len(),
unjudged.len(),
CLASSIFICATION
)
}

pub fn run(cmd: &TypesCmd) -> Result<()> {
let root = repo_root()?;
let all = match cmd {
Expand All @@ -375,6 +439,24 @@ pub fn run(cmd: &TypesCmd) -> Result<()> {
.collect();
return ratchet(&root, &observed, *bless);
}
TypesCmd::Classified => {
let specs = read_specs(&root);
if specs.is_empty() {
anyhow::bail!("no specs under {}/specs -- nothing was read", root.display());
}
let mut by_name: BTreeMap<String, Vec<Def>> = BTreeMap::new();
for (f, src) in &specs {
for (n, d) in defs_in(f, src) {
by_name.entry(n).or_default().push(d);
}
}
let observed: Vec<String> = by_name
.iter()
.filter(|(_, v)| v.len() > 1 && verdict(v) == "CONFLICTED")
.map(|(k, _)| k.clone())
.collect();
return classified(&root, &observed);
}
};
let specs = read_specs(&root);
if specs.is_empty() {
Expand Down
Loading
Loading