Skip to content
70 changes: 70 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,76 @@ This repository contains both Ruff (a Python linter and formatter) and ty (a Pyt

The `ruff_*_spo` crates (`ruff_ruby_spo`, `ruff_python_spo`, `ruff_csharp_spo`, `ruff_cpp_spo`, `ruff_spo_triplet`, …) are the AdaWorldAPI **SPO/transcode** side — AST → `(subject, predicate, object)` fact harvest feeding the OGAR transpiler. Methods for that side are curated in `.claude/knowledge/` (each with a `READ BY:` header) and applied by agents in `.claude/agents/`. **Before harvesting method-body facts or lowering a behaviour arm, read `.claude/knowledge/fuzzy-recipe-codebook.md`** — it teaches how to cook a `(verb, criteria)` recipe codebook and correlate fuzzy imperative bodies to declarative recipes, rather than transcribing bodies. The `fuzzy-proposer` agent carries that method. **Before starting (or resuming) any whole legacy-app→Rust transcode — odoo→odoo-rs, redmine/OpenProject→openproject-nexgen-rs, WoA→woa-rs (worked reference: MedCare→MedCare-rs) — read `.claude/knowledge/consumer-transcode-furnace-playbook.md`** — it frames the loop *around* the codebook: the ore/slag furnace, the two parity oracles (value + Klickwege structure), the three-axis mint gate, the no-hand-roll rule, and a per-consumer portability map.

## Delegation: grindwork goes to Sonnet agents

Fan mechanical work out to Sonnet subagents rather than running it on the main
thread. The split is by SHAPE of the work, not by how hard it looks:

**Sonnet agents** — find every call site of X; quote a definition; trace a
constant's readers; check whether a symbol exists anywhere; run a scoped command
and report the tail; census a corpus.

**Main thread** — whether a review finding is actually correct; what a
measurement means; designing a falsifier; deciding a divergence is defensible
rather than a defect.

The rule bites on SWEEPS — "every site that reads this", "does this exist",
"which of these five shapes are real". For a single lookup where the file and
symbol are already known, going direct is cheaper than briefing an agent.

Never Haiku for any of it. This mirrors the grindwork/accumulation split
already codified in the sibling `lance-graph` workspace.

## Session traps (each one cost real time in this repo)

**Commit BEFORE you disable, not after.** The disable-verification cycle is
`break the guard → watch the test fail → restore`, and the restore is
`git checkout <file>`. That reverts to the last COMMIT, not to the state you
started from — so if the work under test is uncommitted, the restore deletes
it. Measured: a fix plus its new test were both eaten this way, and the loss
was only noticed because a later grep showed the old code back in place.
Order is: commit, then disable, then checkout.

**A disable that does not apply is indistinguishable from a guard that is not
load-bearing.** Both look like "the test still passes". Twice here a patch
script anchored on text that `cargo fmt` had since reformatted, the edit
silently no-opped, and the green run nearly went into the record as evidence
that a guard was inert. Always assert the replacement actually happened:

```python
assert s.count(old) == 1, "anchor moved"
```

Related: turning a knob is only a disable if the knob BINDS. Zeroing a
constant proves nothing when the guarded quantity can reach the same outcome
by another route, or when a different `min()` term is the binding one.

**`git status` reports CLEAN, not CURRENT.** A stale branch does not merely
give stale answers — it manufactures findings that do not exist on `main`.
Before the first measurement in a fresh container, and after any rebase:

```sh
git fetch origin main && git log --oneline -1 origin/main
git merge-base --is-ancestor origin/main HEAD || echo "STALE — re-measure after rebase"
```

Re-run the measurement after a rebase; a conclusion drawn before it does not
survive it, and has previously outlived the rebase in a PR body.

**`prek` cannot run in this sandbox, so Markdown edits ship unchecked.** The
local `uv run --locked prek` fails on a `uv` version that cannot parse this
repo's `exclude-newer`, so the hook set never runs here — and CI's `prek` job
then fails on formatting the edit introduced. It has already happened once, on
the commit that added *this* section. Avoid Markdown constructs `mdformat`
rewrites (pipe tables are padded to the widest cell); prefer prose and bullets,
which it leaves alone. A `prek` green on commit N says nothing about commit N+1.

**A green fixture proves the shape it contains, nothing else.** Several fixes
in this crate were correct for the case in the fixture and wrong for the case
that was not — a bare `lines.each` fixture passing while `self.lines.each`
was silently broken. When a fix distinguishes two shapes, the test carries
both, and asserts they differ.

## Code Review Rules

When reviewing a branch or pull request, be deliberately nitpicky. Report not
Expand Down
132 changes: 132 additions & 0 deletions crates/ruff_ruby_spo/examples/ore_census.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Census the ordered ore over a real Rails tree, so the arm is measured on
//! source it was not written against.
//!
//! `RAILS_SRC=/path/to/openproject cargo run -p ruff_ruby_spo --example ore_census`

#![expect(
clippy::print_stderr,
reason = "census CLI example (mirrors ruff_cpp_spo::harvest_events)"
)]

use std::path::PathBuf;

use ruff_ruby_spo::events::{EventKind, Symbols, source_ore};

fn main() {
// Required, not defaulted. A machine-specific absolute default meant an
// unset variable produced a silent all-zero census with no diagnostic —
// measured once, in this session, and mistaken for a real result.
let Ok(root) = std::env::var("RAILS_SRC") else {
eprintln!("RAILS_SRC is unset — point it at a Rails source tree");
return;
};
let root = PathBuf::from(root);
let limit: usize = std::env::var("LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(usize::MAX);

let mut files = Vec::new();
for dir in ["app/models", "app/services", "app/contracts"] {
collect(&root.join(dir), &mut files);
}
files.sort();
files.truncate(limit);

let mut syms = Symbols::default();
let (mut classes, mut methods, mut events, mut scopes, mut decls) =
(0u64, 0u64, 0u64, 0u64, 0u64);
let (mut set_total, mut ev_facts) = (0u64, 0u64);
let mut with_multi_scope = 0u64;
let mut cb_with_options = 0u64;
let mut kinds: std::collections::BTreeMap<&'static str, u64> =
std::collections::BTreeMap::new();

for path in &files {
let Ok(src) = std::fs::read_to_string(path) else {
continue;
};
for c in source_ore(&src, "op", &mut syms) {
classes += 1;
decls += c.declarations.len() as u64;
for d in &c.declarations {
if let ruff_ruby_spo::Declaration::Callback(cb) = &d.decl
&& !cb.options.is_empty()
{
cb_with_options += 1;
}
}
for m in &c.methods {
methods += 1;
events += m.events.len() as u64;
scopes += m.scopes.len() as u64;
if m.scopes.len() > 1 {
with_multi_scope += 1;
}
// All SIX, including `set_guarded`. Summing five made the
// denominator too small on any corpus containing a
// blank-guarded default, so the preservation ratio was
// overstated — while `MethodOre` carried the sixth count all
// along.
set_total += u64::from(
m.set_reads
+ m.set_writes
+ m.set_guarded
+ m.set_raises
+ m.set_calls
+ m.set_traverses,
);
Comment thread
AdaWorldAPI marked this conversation as resolved.
for e in &m.events {
*kinds.entry(e.kind.as_str()).or_default() += 1;
if matches!(
e.kind,
EventKind::Read
| EventKind::Write
| EventKind::ReadWrite
| EventKind::Raise
| EventKind::Call
) {
ev_facts += 1;
}
}
}
}
}

eprintln!("files {}", files.len());
eprintln!("classes {classes}");
eprintln!("methods {methods}");
eprintln!("declarations {decls} (callbacks carrying options: {cb_with_options})");
eprintln!("events {events}");
eprintln!("scopes {scopes} (methods with >1 scope: {with_multi_scope})");
eprintln!("symbols {}", syms.len());
eprintln!("--");
eprintln!("shipped set entries {set_total}");
eprintln!("ordered fact events {ev_facts}");
if set_total > 0 {
// `f64::from(u32)` is lossless; the counts are corpus-sized, so the
// saturating narrowing is unreachable in practice and never silently
// rounds the way a bare `u64 as f64` would.
let num = f64::from(u32::try_from(ev_facts).unwrap_or(u32::MAX));
let den = f64::from(u32::try_from(set_total).unwrap_or(u32::MAX));
eprintln!("preservation ratio {:.2}x", num / den);
}
eprintln!("--\nevent kinds:");
for (k, n) in &kinds {
eprintln!(" {k:<12} {n}");
}
}

fn collect(dir: &std::path::Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
collect(&p, out);
} else if p.extension().is_some_and(|x| x == "rb") {
out.push(p);
}
}
}
Loading
Loading