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
11 changes: 7 additions & 4 deletions crates/ruff_cpp_codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,20 @@
//!
//! [`decompile`] regenerates the signature-plane triples a manifest encodes;
//! the round-trip `decompile(project(g))` must equal `expand(g)` restricted to
//! the signature plane. That is the `codegen_spine::roundtrip_eq` pattern over
//! the *live harvested* triples (see `manifest::tests`): a manifest that drops a
//! method or mangles a parameter fails it. A green render run is NOT byte-parity
//! the signature plane. That MIRRORS the `codegen_spine::roundtrip_eq` pattern
//! over the *live harvested* triples (see `manifest::tests`): a manifest that
//! drops a method or mangles a parameter fails it. "Mirrors", not "conforms to"
//! — see `manifest`'s module doc for why the correspondence is unchecked.
//!
//! A green render run is NOT byte-parity
//! with libtesseract (that is the operator-gated `PROBE-OGAR-ADAPTER-UNICHARSET`);
//! every emitted file says so via its `PARITY: UNRUN` marker.

mod manifest;
mod render;

pub use manifest::{ClassManifest, MethodSig, decompile, is_signature_plane, project};
pub use render::render;
pub use render::{render, render_with_parity};

#[cfg(test)]
mod integration_tests {
Expand Down
18 changes: 15 additions & 3 deletions crates/ruff_cpp_codegen/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,19 @@
//! method, mangles a parameter, loses a return type, or misses an override edge
//! produces a different `(s, p, o)` set and fails the round-trip. This is the
//! `codegen_spine::roundtrip_eq` pattern (project → decompile → compare against
//! the live harvested triples), implemented over `ruff_spo_triplet::Triple` so
//! the live harvested triples), reimplemented over `ruff_spo_triplet::Triple` so
//! the codegen crate stays `ruff_spo_triplet`-only (no lance-graph edge).
//!
//! **The correspondence is asserted, not checked, and is known to be partial.**
//! `roundtrip_eq` lives in `lance-graph-contract`, which this crate deliberately
//! does not depend on, so nothing here can call it and nothing fails if the two
//! drift. One divergence is already known: `roundtrip_eq`'s truth leg is
//! documented as always run (`0.0` does not skip it), while the comparison here
//! keys on `(s, p, o)` and drops `f`/`c`. That is sound for this plane only
//! because `expand` and `decompile` assign identical provenance to all seven
//! signature predicates — a property of the current expander, not a guarantee.
//! Read every `roundtrip_eq` mention in this crate as naming the pattern being
//! copied, never as a verified equivalence.

use ruff_spo_triplet::{CppMethod, ModelGraph, Predicate, Provenance, Triple};

Expand Down Expand Up @@ -252,8 +263,9 @@ mod tests {
}

/// The teeth: regenerating the signature plane from the projected manifest
/// equals `expand`'s signature-plane output for the same graph. This is the
/// `codegen_spine::roundtrip_eq` pattern over the live harvested triples.
/// equals `expand`'s signature-plane output for the same graph. This mirrors
/// the `codegen_spine::roundtrip_eq` pattern over the live harvested triples
/// (unchecked correspondence — see the module doc).
#[test]
fn decompile_roundtrips_signature_plane_against_expand() {
let g = fixture();
Expand Down
26 changes: 24 additions & 2 deletions crates/ruff_cpp_codegen/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,36 @@ const HAND_PORT: &[(&str, &str, &str)] = &[
),
];

/// Render the method manifests as Rust source. See the module docs.
/// The parity status of the original Tesseract arm, whose byte-parity probe is
/// blocked on a leptonica build environment.
const TESSERACT_PARITY: &str = "UNRUN (operator-blocked: leptonica)";

/// Render the method manifests as Rust source, with the Tesseract arm's parity
/// status. See the module docs.
///
/// A second corpus must NOT use this: the emitted marker names leptonica, and a
/// harvest of some other corpus would then publish an operator block that has
/// nothing to do with it. Call [`render_with_parity`] instead.
#[must_use]
pub fn render(manifests: &[ClassManifest]) -> String {
render_with_parity(manifests, TESSERACT_PARITY)
}

/// Render the method manifests as Rust source, stating `parity` as the emitted
/// `PARITY:` status.
///
/// The status is a caller fact, not a renderer fact — which corpus is blocked,
/// and on what, is known where the harvest runs. Hardcoding one corpus's
/// blocker made every other consumer's artifact assert something false about
/// itself, which is worse than saying nothing: a reader who trusts the marker
/// is misled about what was verified.
#[must_use]
pub fn render_with_parity(manifests: &[ClassManifest], parity: &str) -> String {
// Writing to a `String` via `fmt::Write` is infallible; the `Result` is
// discarded deliberately.
let mut out = String::new();
let _ = writeln!(out, "//! @generated by ruff_cpp_codegen -- DO NOT EDIT.");
let _ = writeln!(out, "//! PARITY: UNRUN (operator-blocked: leptonica)");
let _ = writeln!(out, "//! PARITY: {parity}");
let _ = writeln!(out, "//!");
let _ = writeln!(
out,
Expand Down
4 changes: 4 additions & 0 deletions crates/ruff_cpp_spo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ ruff_cpp_codegen = { workspace = true }
[lints]
workspace = true

[[example]]
name = "harvest_openggs"
required-features = ["libclang"]

[[example]]
name = "harvest_events"
required-features = ["libclang"]
Expand Down
291 changes: 291 additions & 0 deletions crates/ruff_cpp_spo/examples/harvest_openggs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
//! THE `OpenGGS` signature harvest — the C-library arm wired end to end, from
//! libclang to rendered Rust, with the round-trip oracle as the gate.
//!
//! `OpenGGS` (<https://github.com/bugix/OpenGGS>) is plain C: 25 file-scope
//! structs and ~181 free functions, no classes. So the class plane
//! (`walk_tu` → `CppClass` → `model_from_class`) harvests NOTHING from it, and
//! the C-library arm ([`walk_free_functions`]) is the whole story.
//!
//! ```text
//! OpenGGS/src/*.cpp --(walk_free_functions)--> CppFunction
//! --(one Model per TU)--> ModelGraph
//! --(expand)--> Vec<Triple> the SPO plane
//! --(ruff_cpp_codegen::project)--> ClassManifest
//! --(decompile ∘ project == expand | signature)--> THE ORACLE
//! --(render)--> Rust `&[MethodSig]` const tables
//! ```
//!
//! # A translation unit is the Model
//!
//! `project` keys manifests by `Model::name`, and a C library has no class to
//! key on. The translation unit is the honest unit: it is what the C programmer
//! used for grouping and for internal linkage (`static` = TU-private), so
//! `GAME_ENVIRONMENT.cpp`'s functions become the `GAME_ENVIRONMENT` manifest.
//! Inventing one synthetic class for the whole corpus would throw that grouping
//! away; inventing one per function would make the manifest layer meaningless.
//!
//! # What this generates, and what it does NOT
//!
//! Signature manifests — names, ordered parameter types, return types, linkage.
//! **Not bodies.** That is the 85/15 split: this mints the mechanical structure
//! that classifies and orders the transcode, and the function bodies remain the
//! essential-15% hand-port. A generated `MethodSig` table is a map, not a port.
//!
//! Env:
//! `GGS_SRC` corpus directory (default `/home/user/OpenGGS/src`)
//! `GGS_ARGS` extra clang args, space-separated
//! (default `-I/usr/include/SDL2`)
//! `GGS_OUT` artifact directory (default `.claude/harvest/openggs`)
//!
//! A missing corpus is a printed skip, never a failure, so this runs on any
//! checkout. The corpus is read locally and never redistributed.
//!
//! ```sh
//! LIBCLANG_PATH=/usr/lib/llvm-18/lib \
//! cargo run -p ruff_cpp_spo --features libclang --example harvest_openggs
//! ```

#![expect(
clippy::print_stderr,
reason = "manifest-emission CLI example (mirrors harvest_ladybug)"
)]

use std::collections::BTreeSet;
use std::path::PathBuf;

use ruff_cpp_spo::{NAMESPACE, walk_free_functions_with_diagnostics};
use ruff_spo_triplet::{CppMethod, Model, ModelGraph, Triple, expand, to_ndjson};

fn env_or(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}

/// The translation units of the corpus, sorted so the harvest is reproducible.
fn translation_units(src: &PathBuf) -> std::io::Result<Vec<PathBuf>> {
let mut out: Vec<PathBuf> = std::fs::read_dir(src)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "cpp"))
.collect();
out.sort();
Ok(out)
}

/// Run the harvest.
///
/// Two failure postures, deliberately different: a MISSING corpus is a printed
/// skip and `Ok`, so this example runs on any checkout; a FAILED ORACLE is a
/// hard error that writes nothing, because a manifest whose IRIs disagree with
/// the triples is worse than no manifest. A translation unit that parses but
/// emits an error diagnostic is skipped rather than harvested — see the gate in
/// the loop for why the oracle cannot catch that case.
fn main() -> Result<(), Box<dyn std::error::Error>> {
let src = PathBuf::from(env_or("GGS_SRC", "/home/user/OpenGGS/src"));
if !src.is_dir() {
eprintln!("[ggs] skip: no corpus at {}", src.display());
return Ok(());
}
let mut args: Vec<String> = env_or("GGS_ARGS", "-I/usr/include/SDL2")
.split_whitespace()
.map(str::to_string)
.collect();
// The corpus's own headers are beside its sources; every TU includes
// `globals.h` by bare name.
args.push(format!("-I{}", src.display()));

let units = translation_units(&src)?;
// The namespace lives on the GRAPH, not the model, and `decompile` is told
// the same one below. Building the graph with `default()` leaves it empty,
// which keys every manifest IRI differently from the expanded triples — the
// oracle caught exactly that on the first run.
let mut graph = ModelGraph::new(NAMESPACE);
let (mut n_functions, mut n_failed, mut n_diagnostics) = (0usize, 0usize, 0usize);

for tu in &units {
let stem = tu
.file_stem()
.map_or_else(String::new, |s| s.to_string_lossy().into_owned());
let (funcs, diagnostics) = match walk_free_functions_with_diagnostics(tu, &args) {
Ok(fd) => fd,
Err(e) => {
eprintln!("[ggs] {stem}: parse failed: {e}");
n_failed += 1;
continue;
}
};
// A TU that parsed but emitted an error diagnostic is NOT usable ore.
// libclang recovers from an unresolved #include by dropping the
// declarations that needed it, with no Err and no marker — so the
// functions are simply absent, and the round-trip oracle below cannot
// see the loss (it compares a projection of the graph against an
// expansion of the SAME graph; both are equally short). Refuse the unit
// rather than mint a manifest from a partial parse.
if !diagnostics.is_empty() {
eprintln!(
"[ggs] {stem}: SKIPPED — {} error diagnostic(s); first: {}",
diagnostics.len(),
diagnostics[0]
);
for d in diagnostics.iter().skip(1).take(2) {
eprintln!("[ggs] also: {d}");
}
n_diagnostics += 1;
continue;
}
if funcs.is_empty() {
continue;
}
// A function DEFINED in a header reachable from several TUs is harvested
// once per TU. Keeping the first sighting inside one model is a corpus
// decision, not a dedup of occurrences.
//
// The key is (QUALIFIED name, ordered parameter types), and both halves
// are load-bearing. Parameter types, because this corpus is compiled as
// C++, so `f(int)` and `f(double)` are two distinct functions a
// name-only key would collapse into one. The qualified name, because
// `CppFunction::name` is bare while scope is captured separately, so
// `a::f(int)` and `b::f(int)` collide on the bare name — and they would
// then collide again in the method IRI, which is
// `{model_iri}.{name}({params})` with no scope component.
//
// The oracle cannot catch either collapse: it compares a projection of
// the graph against an expansion of the SAME graph, so both sides are
// equally short. The class plane already uses the qualified name for
// exactly this — as its dedup key in `walk_tu` and as the `Model::name`
// in `model_from_class` — so this mirrors an established identity
// rather than inventing one.
let mut seen = BTreeSet::new();
let mut model = Model::new(&stem);
for f in funcs {
let identity = f.qualified_name();
if !seen.insert((identity.clone(), f.param_types.clone())) {
continue;
}
n_functions += 1;
// `CppFunction::is_static` (C internal linkage) is deliberately NOT
// mapped onto `CppMethod::is_static`. That predicate means "a
// class-level member with no implicit `this`", and a free function
// is not a class member at all, so neither value is a true claim
// about it: `true` would assert class membership, `false` would
// assert an implicit receiver these functions do not have. The
// expander emits `is_static` only when the field is true, so
// leaving it at the default makes NO claim — which is the honest
// encoding. Linkage stays on `CppFunction` for a consumer reading
// the harvest directly; representing it in the shared IR would need
// its own predicate, and that is a deliberate ontology change, not
// something to smuggle in by overloading an existing one.
// The qualified name is what reaches the IR, so the method IRI
// carries the scope the shared `CppMethod` has no field for.
// Adding one would be an ontology change; a scope-qualified name is
// still a name, and it is how C++ itself writes the identity. At
// global scope this is the bare name, so a C corpus is unaffected.
model.methods.push(CppMethod {
name: identity,
return_type: f.return_type,
param_types: f.param_types,
..CppMethod::default()
Comment thread
AdaWorldAPI marked this conversation as resolved.
});
}
graph.models.push(model);
}

// `CppFunction::calls` is deliberately NOT mapped onto `CppMethod::calls`,
// and unlike the `is_static` decision above this one has NO guard behind it
// — so the invariant is asserted here rather than described.
//
// The two fields share a name and a `Vec<String>` and mean different
// things: `CppFunction::calls` is EVERY `CallExpr` callee as a bare name,
// while `CppMethod::calls` is the closed `ActiveRecord` mutator set as
// `"<receiver>.<method>"` and expands to "this method calls a writer".
// Mapping one onto the other would mint every-callee facts as
// lifecycle-mutator claims, and every guard in this arm is blind to it:
// the compiler and clippy see the same name and type; the oracle below
// cannot see it because `Calls` is not on the signature plane
// (`ruff_cpp_codegen::is_signature_plane`); and a truth-leg check could
// not either, because both `Predicate::Calls` emission sites stamp
// `Provenance::Inferred`, so `f`/`c` are identical either way.
//
// The free-function call graph wants its own predicate — the other
// `walk_free_functions` consumer already prints it as `dispatches_to` —
// and that is a deliberate ontology change, not this example's to make.
assert!(
graph
.models
.iter()
.flat_map(|m| &m.methods)
.all(|m| m.calls.is_empty()),
"CppFunction::calls is every callee; CppMethod::calls is the mutator set only"
);

let triples = expand(&graph);

// ── THE ORACLE ───────────────────────────────────────────────────────────
// `decompile(project(g))` must equal `expand(g)` restricted to the
// signature plane. This is not a self-golden: the two sides are computed by
// different code (the expander vs the manifest projection), so a manifest
// that drops a method or reorders a parameter list fails here. A run that
// cannot prove it refuses to write anything.
let manifests = ruff_cpp_codegen::project(&graph);
let round_tripped: BTreeSet<String> = ruff_cpp_codegen::decompile(&manifests, NAMESPACE)
.iter()
.map(key)
.collect();
let expected: BTreeSet<String> = triples
.iter()
.filter(|t| ruff_cpp_codegen::is_signature_plane(t))
.map(key)
.collect();

let missing: Vec<&String> = expected.difference(&round_tripped).collect();
let extra: Vec<&String> = round_tripped.difference(&expected).collect();
if !missing.is_empty() || !extra.is_empty() {
eprintln!(
"[ggs] ORACLE FAILED: {} signature triples lost, {} invented",
missing.len(),
extra.len()
);
for k in missing.iter().take(5) {
eprintln!(" lost: {k}");
}
for k in extra.iter().take(5) {
eprintln!(" invented: {k}");
}
return Err("signature round-trip mismatch — nothing written".into());
}

let out = PathBuf::from(env_or("GGS_OUT", ".claude/harvest/openggs"));
std::fs::create_dir_all(&out)?;
// This corpus's own parity status, not the Tesseract arm's. `render` would
// stamp "operator-blocked: leptonica" onto an artifact that has nothing to
// do with leptonica — an artifact asserting something false about its own
// verification is worse than one that says nothing.
let rendered = ruff_cpp_codegen::render_with_parity(
&manifests,
"UNRUN (no execution oracle: signatures only, bodies are hand-port)",
);
std::fs::write(out.join("openggs_methods.rs"), &rendered)?;

std::fs::write(out.join("triples.ndjson"), to_ndjson(&triples))?;

eprintln!(
"[ggs] {} TUs ({n_failed} failed to parse, {n_diagnostics} skipped on error \
diagnostics), {} models, {n_functions} functions",
units.len(),
graph.models.len()
);
eprintln!(
"[ggs] {} triples ({} on the signature plane)",
triples.len(),
expected.len()
);
eprintln!("[ggs] ORACLE PASSED: decompile(project(g)) == expand(g) | signature plane");
eprintln!("[ggs] artifacts -> {}", out.display());
Ok(())
}

/// A triple's identity for set comparison. Provenance is deliberately excluded:
/// the oracle is about WHICH facts survive the projection, not about how each
/// side labelled its own derivation.
fn key(t: &Triple) -> String {
format!("{}\t{}\t{}", t.s, t.p, t.o)
}
Loading
Loading