From 1db31bcf31b4e5c4de4a36d770ead8920ba53977 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:05:12 +0000 Subject: [PATCH 1/2] ogar-render-askama: render ClassView x FieldMask -> struct + ActionDef methods (transpile-chain LEG 3) The render end of the transpile chain. New render_class_with_methods(class, mask, actions): a compile-time (askama = ERB analog) transpiler emitting a Rust struct whose FIELDS are the ClassView x FieldMask projection (the bitmask indexes the ObjectView N3 order - attributes then family edges - the basis OgarClassView::render_rows uses) and whose METHODS are the OGAR ActionDef DO-arm, as a struct-of-methods constructor (impl { new(..) + one fn per ActionDef }). Operator rulings (2026-07-04): - Behaviour is Rust methods, NOT SurrealQL DDL. The deprecated SurrealQL-AST adapter (DEFINE EVENT ... WHEN ... THEN ...) is not a target; consistent with SURREAL-AST-AS-ADAPTER.md. Tests assert no DEFINE EVENT / DEFINE TABLE. - on_enter (the Rubicon state mutation) -> method takes &mut self; read actions take &self. New dep lance-graph-contract (FieldMask, branch=main). 6 new tests (mask gates fields; ActionDef->methods; dotted-predicate + PascalCase sanitisers); 50 tests green; workspace check clean; clippy -D warnings clean. End-to-end verified: masked account.move (mask={0,2}) -> struct AccountMove { name, state } + fn new(name, state) + fn action_post(&mut self), CLASS_ID=0x0202. Also fixes a pre-existing clippy::cloned_ref_to_slice_refs (1.95 toolchain) in the list_view test. Ledger: docs/DISCOVERY-MAP.md D-OGAR-RENDER-CLASSVIEW-FIELDMASK-METHODS. --- crates/ogar-render-askama/Cargo.toml | 1 + .../src/artifact_kinds/rust_struct.rs | 6 +- crates/ogar-render-askama/src/lib.rs | 13 +- crates/ogar-render-askama/src/rust_class.rs | 374 ++++++++++++++++++ .../templates/rust_class.askama | 40 ++ docs/DISCOVERY-MAP.md | 27 ++ 6 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 crates/ogar-render-askama/src/rust_class.rs create mode 100644 crates/ogar-render-askama/templates/rust_class.askama diff --git a/crates/ogar-render-askama/Cargo.toml b/crates/ogar-render-askama/Cargo.toml index c655ef8..cf50bcc 100644 --- a/crates/ogar-render-askama/Cargo.toml +++ b/crates/ogar-render-askama/Cargo.toml @@ -7,4 +7,5 @@ description = "Build-time askama codegen harness over the canonical layer — on [dependencies] ogar-vocab = { path = "../ogar-vocab" } +lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main" } askama = "0.12" diff --git a/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs b/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs index 9da2d1a..f42b183 100644 --- a/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs +++ b/crates/ogar-render-askama/src/artifact_kinds/rust_struct.rs @@ -116,7 +116,7 @@ impl ArtifactEmitter for RustStructEmitter { /// 2024 strict + reserved-future); a `&str` slot from the canonical layer /// can never need a non-identifier escape since names are sourced from /// Rails / Odoo identifiers. -fn escape_rust_ident(name: &str) -> String { +pub(crate) fn escape_rust_ident(name: &str) -> String { const RESERVED: &[&str] = &[ // Rust 2015+ strict keywords: "as", "break", "const", "continue", "crate", "else", "enum", "extern", @@ -142,7 +142,7 @@ fn escape_rust_ident(name: &str) -> String { /// today. Each `op-*` / `rm-*` consumer is free to specialise (e.g. /// `Decimal` vs `f64` for monetary slots) downstream. The point is the /// canonical contract round-trips; precision is a per-consumer concern. -fn rails_to_rust_type(t: Option<&str>) -> String { +pub(crate) fn rails_to_rust_type(t: Option<&str>) -> String { match t { Some("string") | Some("text") => "String".into(), Some("integer") | Some("big_integer") | Some("bigint") => "i64".into(), @@ -155,7 +155,7 @@ fn rails_to_rust_type(t: Option<&str>) -> String { } } -fn edge_rust_type(a: &ogar_vocab::Association) -> String { +pub(crate) fn edge_rust_type(a: &ogar_vocab::Association) -> String { // Coarse: `belongs_to` / `has_one` → `Option` (FK id), // `has_many` / `habtm` → `Vec`. The concrete `op-*` / `rm-*` // consumer can swap these for typed references downstream. diff --git a/crates/ogar-render-askama/src/lib.rs b/crates/ogar-render-askama/src/lib.rs index 00d3e3f..e624948 100644 --- a/crates/ogar-render-askama/src/lib.rs +++ b/crates/ogar-render-askama/src/lib.rs @@ -71,6 +71,7 @@ pub mod artifact_kinds; pub mod form_view; pub mod list_view; +pub mod rust_class; pub mod spec; pub use artifact_kinds::{ @@ -80,6 +81,7 @@ pub use artifact_kinds::{ }; pub use form_view::{default_input_kind_for, InputKind}; pub use list_view::{default_kind_for, ColumnKind, RenderColumn, SortOrder}; +pub use rust_class::render_class_with_methods; pub use spec::{ArtifactKind, ArtifactSpec}; use ogar_vocab::Class; @@ -329,8 +331,15 @@ mod tests { }], block: vec![], }; - let src = render_list("By status", 0x0102, "project_work_item", &[col.clone()], &[], &[row]) - .unwrap(); + let src = render_list( + "By status", + 0x0102, + "project_work_item", + std::slice::from_ref(&col), + &[], + &[row], + ) + .unwrap(); assert!(src.contains("class=\"group open\""), "{src}"); assert!(src.contains("Open"), "{src}"); assert!(src.contains("5"), "{src}"); diff --git a/crates/ogar-render-askama/src/rust_class.rs b/crates/ogar-render-askama/src/rust_class.rs new file mode 100644 index 0000000..945496c --- /dev/null +++ b/crates/ogar-render-askama/src/rust_class.rs @@ -0,0 +1,374 @@ +//! LEG 3 — the compile-time ERB/askama transpiler for a full canonical class: +//! **ClassView × FieldMask → struct**, plus the OGAR `ActionDef` DO-arm → +//! a **struct-of-methods constructor**. +//! +//! Where [`RustStruct`](crate::ArtifactKind::RustStruct) emits the *whole* +//! class as a flat data struct, this path emits the **masked** projection and +//! attaches the behavioural arm: +//! +//! ```text +//! ClassView (ObjectView field basis) × FieldMask (presence bits) +//! │ │ +//! └──────────────┬─────────────────────┘ +//! ▼ +//! struct fields (only present bits) +//! + +//! impl { new(..) ctor + one fn per ActionDef } ← DO-arm +//! │ +//! askama (the ERB/XSLT analog), compile-time +//! ``` +//! +//! Two deliberate rulings baked in: +//! +//! 1. **The FieldMask indexes the ObjectView N3 order** — attributes first +//! (declaration order), then family-edge associations — the exact basis +//! [`OgarClassView::render_rows`](../../ogar_class_view) projects. Bit `n` +//! set ⇒ the `n`-th field emits; [`FieldMask(u64::MAX)`] emits every field. +//! 2. **Behaviour is Rust methods, never SurrealQL DDL.** The DO-arm +//! ([`ActionDef`]) materialises as methods on the struct via a +//! constructor-opened `impl` block. The deprecated SurrealQL-AST adapter +//! (`DEFINE EVENT … WHEN … THEN …` carrying lifecycle) is NOT a target +//! here — behaviour flows producer → OGAR `ActionDef` → Rust method. + +use askama::Template; +use lance_graph_contract::class_view::FieldMask; +use ogar_vocab::{canonical_concept_id, ActionDef, AssociationKind, Class}; + +use crate::artifact_kinds::rust_struct::{edge_rust_type, escape_rust_ident, rails_to_rust_type}; + +/// askama-bound context for `templates/rust_class.askama`. +#[derive(Template)] +#[template(path = "rust_class.askama", escape = "none")] +struct RustClassCtx { + name: String, + concept_fn: String, + canonical_concept: String, + /// Hex class id (`"0x0102"`) or empty when the concept isn't in the + /// codebook (the template branches on length). + class_id_hex: String, + fields: Vec, + /// Precomputed `"a: String, b: i64"` constructor parameter list — built + /// in Rust so the template never has to juggle loop-comma joins. + ctor_params: String, + /// Precomputed `"a, b"` field-init list for the constructor body. + ctor_inits: String, + methods: Vec, +} + +struct RustField { + snake_name: String, + rust_type: String, + /// `"attribute"` / `"belongs_to"` / `"has_many"` … — a doc hint naming + /// which ClassView axis the field came from. + origin: String, +} + +struct RustMethod { + fn_name: String, + predicate: String, + /// Decorator provenance (`"decorators: api.depends"`), empty when none. + doc: String, + /// A one-line provenance comment for the method body (first source line + /// or a TODO pointing at the canonical `object_class`). + body_comment: String, + /// `&mut self` when the action declares an `on_enter` state mutation + /// (the Rubicon crossing writes a field); `&self` otherwise. + mutates: bool, +} + +/// Render a canonical [`Class`] as a Rust struct whose FIELDS are the +/// `ClassView × FieldMask` projection and whose METHODS are the OGAR +/// [`ActionDef`] DO-arm, assembled as a struct-of-methods constructor. +/// +/// - `mask` indexes the ObjectView N3 field order (attributes then family +/// edges). An all-bits-set mask (`FieldMask(u64::MAX)`) emits every field; +/// any other mask emits only the fields whose bit is set (positions +/// `>= FieldMask::MAX_FIELDS` can't be represented and drop — matching the +/// contract's 64-field ceiling). +/// - `actions` are the ActionDefs the caller has already filtered to this +/// class (`ActionDef::object_class` == this class). The render crate stays +/// a pure projection — it never scans a global action table. +/// +/// # Errors +/// +/// Propagates [`askama::Error`] if template rendering fails (it never should +/// for well-formed input — the template has no fallible expressions). +pub fn render_class_with_methods( + class: &Class, + mask: FieldMask, + actions: &[ActionDef], +) -> Result { + let concept = class.canonical_concept.as_deref().unwrap_or(""); + let class_id_hex = canonical_concept_id(concept) + .map(|id| format!("0x{id:04X}")) + .unwrap_or_default(); + + // ObjectView N3 order: attributes, then associations. `idx` walks the + // combined sequence; the mask gates each position. + let mut fields = Vec::new(); + let mut idx: u8 = 0; + for a in &class.attributes { + if field_present(mask, idx) { + fields.push(RustField { + snake_name: escape_rust_ident(&a.name), + rust_type: rails_to_rust_type(a.type_name.as_deref()), + origin: "attribute".to_string(), + }); + } + idx = idx.saturating_add(1); + } + for e in &class.associations { + if field_present(mask, idx) { + fields.push(RustField { + snake_name: escape_rust_ident(&e.name), + rust_type: edge_rust_type(e), + origin: assoc_origin(e.kind), + }); + } + idx = idx.saturating_add(1); + } + + let ctor_params = fields + .iter() + .map(|f| format!("{}: {}", f.snake_name, f.rust_type)) + .collect::>() + .join(", "); + let ctor_inits = fields + .iter() + .map(|f| f.snake_name.clone()) + .collect::>() + .join(", "); + + let methods = actions.iter().map(lift_method).collect(); + + let ctx = RustClassCtx { + name: pascal_type_name(&class.name), + concept_fn: concept.to_string(), + canonical_concept: concept.to_string(), + class_id_hex, + fields, + ctor_params, + ctor_inits, + methods, + }; + ctx.render() +} + +/// An all-bits-set mask is the "unmasked" sentinel (emit everything, +/// including any field beyond the 64-bit ceiling). Any narrower mask consults +/// the bit — and a position past `MAX_FIELDS` can't be present, so it drops. +fn field_present(mask: FieldMask, idx: u8) -> bool { + if mask.0 == u64::MAX { + true + } else if (idx as u32) < FieldMask::MAX_FIELDS { + mask.has(idx) + } else { + false + } +} + +/// Doc label for the ClassView axis a family edge came from. +fn assoc_origin(kind: AssociationKind) -> String { + match kind { + AssociationKind::BelongsTo => "belongs_to".into(), + AssociationKind::HasOne => "has_one".into(), + AssociationKind::HasMany => "has_many".into(), + AssociationKind::HasAndBelongsToMany => "has_and_belongs_to_many".into(), + _ => "family_edge".into(), + } +} + +/// Lift one OGAR [`ActionDef`] onto a method skeleton. The predicate becomes +/// the fn name; decorators become a doc line; `on_enter` (a state mutation on +/// the Rubicon crossing) makes the method take `&mut self`. +fn lift_method(a: &ActionDef) -> RustMethod { + let fn_name = escape_rust_ident(&sanitize_ident(&a.predicate)); + let doc = if a.decorators.is_empty() { + String::new() + } else { + format!("decorators: {}", a.decorators.join(", ")) + }; + let body_comment = match &a.body_source { + Some(b) if !b.trim().is_empty() => { + let first = b.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim(); + format!("// ported from source: {}", one_line(first)) + } + _ => format!("// TODO: port `{}` from {}", a.predicate, a.object_class), + }; + RustMethod { + fn_name, + predicate: a.predicate.clone(), + doc, + body_comment, + mutates: a.on_enter.is_some(), + } +} + +/// Collapse a source snippet to a single safe comment line. +fn one_line(s: &str) -> String { + s.chars() + .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) + .take(100) + .collect() +} + +/// Reduce an arbitrary source name to a snake-case Rust identifier body: +/// lowercase, `[a-z0-9_]` kept, everything else → `_`. A leading digit is +/// prefixed with `_` so the result is a legal identifier. +fn sanitize_ident(s: &str) -> String { + let mut out: String = s + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect(); + if out.is_empty() { + out.push_str("action"); + } else if out.as_bytes()[0].is_ascii_digit() { + out.insert(0, '_'); + } + out +} + +/// Turn an arbitrary class name (`"account.move"`, `"work_package"`, +/// `"WorkPackage"`) into a valid PascalCase Rust type identifier. Splits on +/// any non-alphanumeric boundary AND on existing camel/Pascal humps are kept +/// verbatim — a name that is already PascalCase (`"WorkPackage"`) round-trips. +fn pascal_type_name(raw: &str) -> String { + let mut out = String::new(); + let mut cap_next = true; + for c in raw.chars() { + if c.is_ascii_alphanumeric() { + if cap_next { + out.push(c.to_ascii_uppercase()); + cap_next = false; + } else { + out.push(c); + } + } else { + // any separator (`.`, `_`, `-`, space) starts a new hump + cap_next = true; + } + } + if out.is_empty() { + out.push_str("Anonymous"); + } else if out.as_bytes()[0].is_ascii_digit() { + out.insert(0, '_'); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use ogar_vocab::{ActionDef, Attribute, EnterEffect}; + + /// A small hand-built class: 2 attributes + 1 family edge, with one + /// mutating action and one read action. + fn sample_class() -> Class { + let mut c = Class::new("account.move"); + c.canonical_concept = Some("commercial_document".to_string()); + // `Attribute` is `#[non_exhaustive]` — build via `Default` + field + // assignment (fields are `pub`). + let mut name = Attribute::default(); + name.name = "name".to_string(); + name.type_name = Some("string".to_string()); + let mut amount = Attribute::default(); + amount.name = "amount_total".to_string(); + amount.type_name = Some("decimal".to_string()); + c.attributes = vec![name, amount]; + c + } + + /// `ActionDef` is `#[non_exhaustive]`, so build via `Default` + field + /// assignment (the fields are all `pub`) rather than a struct literal. + fn sample_actions() -> Vec { + let mut post = ActionDef::default(); + post.identity = "ogit-erp/account.move::action_def::action_post".to_string(); + post.predicate = "action_post".to_string(); + post.object_class = "ogit-erp/account.move".to_string(); + post.on_enter = Some(EnterEffect::transition("state", "posted")); + + let mut name_get = ActionDef::default(); + name_get.identity = "ogit-erp/account.move::action_def::name_get".to_string(); + name_get.predicate = "name.get".to_string(); // dotted → sanitised + name_get.object_class = "ogit-erp/account.move".to_string(); + name_get.decorators = vec!["api.depends".to_string()]; + + vec![post, name_get] + } + + #[test] + fn full_mask_emits_all_fields_and_the_ctor() { + let class = sample_class(); + let src = render_class_with_methods(&class, FieldMask(u64::MAX), &[]).unwrap(); + // PascalCase type name derived from the dotted Odoo name. + assert!(src.contains("pub struct AccountMove"), "{src}"); + // Both attributes present under FULL. + assert!(src.contains("pub name: String,"), "{src}"); + assert!(src.contains("pub amount_total: f64,"), "{src}"); + // The struct-of-methods constructor over the masked field set. + assert!( + src.contains("pub fn new(name: String, amount_total: f64) -> Self"), + "{src}" + ); + assert!(src.contains("pub const CLASS_ID: u16 = 0x"), "{src}"); + } + + #[test] + fn field_mask_gates_which_fields_emit() { + // Bit 0 = first attribute (`name`); drop bit 1 (`amount_total`). + let mask = FieldMask::EMPTY.with(0); + let class = sample_class(); + let src = render_class_with_methods(&class, mask, &[]).unwrap(); + assert!(src.contains("pub name: String,"), "kept field 0:\n{src}"); + assert!( + !src.contains("amount_total"), + "field 1 should be masked out:\n{src}" + ); + // The constructor tracks the mask — only the present field. + assert!(src.contains("pub fn new(name: String) -> Self"), "{src}"); + } + + #[test] + fn action_defs_become_struct_methods_do_arm() { + let class = sample_class(); + let actions = sample_actions(); + let src = render_class_with_methods(&class, FieldMask(u64::MAX), &actions).unwrap(); + // Each ActionDef → one method; the dotted predicate is sanitised. + assert!(src.contains("pub fn action_post(&mut self)"), "mutating action → &mut self:\n{src}"); + assert!(src.contains("pub fn name_get(&self)"), "read action → &self, dotted name sanitised:\n{src}"); + // Provenance: the api.depends decorator surfaces in the doc. + assert!(src.contains("api.depends"), "{src}"); + // No SurrealQL DDL anywhere — behaviour is Rust methods only. + assert!(!src.contains("DEFINE EVENT"), "no SurrealQL AST adapter:\n{src}"); + assert!(!src.contains("DEFINE TABLE"), "{src}"); + } + + #[test] + fn empty_class_emits_valid_shell() { + let mut c = Class::new("Bare"); + c.canonical_concept = None; + let src = render_class_with_methods(&c, FieldMask(u64::MAX), &[]).unwrap(); + assert!(src.contains("pub struct Bare"), "{src}"); + assert!(src.contains("pub fn new() -> Self"), "{src}"); + } + + #[test] + fn sanitize_ident_handles_dotted_and_leading_digit() { + assert_eq!(sanitize_ident("action_post"), "action_post"); + assert_eq!(sanitize_ident("name.get"), "name_get"); + assert_eq!(sanitize_ident("3d_render"), "_3d_render"); + } + + #[test] + fn pascal_type_name_round_trips_and_normalises() { + assert_eq!(pascal_type_name("account.move"), "AccountMove"); + assert_eq!(pascal_type_name("work_package"), "WorkPackage"); + assert_eq!(pascal_type_name("WorkPackage"), "WorkPackage"); + } +} diff --git a/crates/ogar-render-askama/templates/rust_class.askama b/crates/ogar-render-askama/templates/rust_class.askama new file mode 100644 index 0000000..711fd9e --- /dev/null +++ b/crates/ogar-render-askama/templates/rust_class.askama @@ -0,0 +1,40 @@ +{# Render one canonical class as a Rust struct (ClassView × FieldMask #} +{# projection) + a struct-of-methods constructor (OGAR ActionDef DO-arm). #} +{# DO NOT EDIT BY HAND. Re-render via #} +{# `ogar_render_askama::render_class_with_methods`. #} +//! `{{ name }}` — canonical class generated from `ogar_vocab::{{ concept_fn }}()`. +//! Fields are the ClassView × FieldMask projection; methods are the OGAR +//! `ActionDef` DO-arm (behaviour is Rust, never SurrealQL DDL). +//! DO NOT EDIT BY HAND. Re-render via `ogar-render-askama`. + +/// Canonical concept name as in the OGAR codebook. +pub const CANONICAL_CONCEPT: &str = "{{ canonical_concept }}"; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct {{ name }} { +{%- for f in fields %} + /// {{ f.origin }} `{{ f.snake_name }}`. + pub {{ f.snake_name }}: {{ f.rust_type }}, +{%- endfor %} +} + +impl {{ name }} { +{%- if class_id_hex.len() > 0 %} + /// Canonical codebook id for this class. + pub const CLASS_ID: u16 = {{ class_id_hex }}; +{%- endif %} + + /// Struct-of-methods constructor over the ClassView × FieldMask field set. + pub fn new({{ ctor_params }}) -> Self { + Self { {{ ctor_inits }} } + } +{% for m in methods %} + /// OGAR action `{{ m.predicate }}` (DO-arm). +{%- if m.doc.len() > 0 %} + /// {{ m.doc }} +{%- endif %} + pub fn {{ m.fn_name }}(&{% if m.mutates %}mut {% endif %}self) { + {{ m.body_comment }} + } +{% endfor -%} +} diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index f9a2e8f..252ea61 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -739,3 +739,30 @@ isolation. The map's job is to keep them visible. clippy clean. Supersedes the "widen parent vs primary+relation" framing in the 2026-07-04 lance-graph broadcast — the vocab's mixins axis is the answer. + +- **D-OGAR-RENDER-CLASSVIEW-FIELDMASK-METHODS (transpile-chain LEG 3; + 2026-07-04; [G] — CODED + tested):** the render end of the operator's + chain (`ruff harvest → ogar-from-ruff lift → CompiledClass → + ClassView × FieldMask → askama render`). `ogar-render-askama` gains + `render_class_with_methods(class, mask, actions)` — a compile-time + (askama = the ERB/XSLT analog) transpiler that emits a Rust struct + whose FIELDS are the `ClassView × FieldMask` projection (the + `FieldMask` bitmask indexes the ObjectView N3 order — attributes then + family edges — the exact basis `OgarClassView::render_rows` uses; bit + `n` set ⇒ n-th field emits) and whose METHODS are the OGAR `ActionDef` + DO-arm, assembled as a **struct-of-methods constructor** (`impl { new(..) + ctor + one fn per ActionDef }`). **Operator rulings baked in + (2026-07-04):** (1) behaviour is Rust methods, NOT SurrealQL DDL — the + deprecated SurrealQL-AST adapter (`DEFINE EVENT … WHEN … THEN …` + carrying lifecycle) is not a target; consistent with + `SURREAL-AST-AS-ADAPTER.md` §0. (2) `on_enter` (the Rubicon state + mutation) makes a method take `&mut self`; read actions take `&self`. + New dep: `lance-graph-contract` (for `FieldMask`, branch=main). 6 new + tests (mask gates fields; ActionDef→methods; no `DEFINE EVENT`/`DEFINE + TABLE`; dotted-predicate + PascalCase sanitisers); 50 tests green in + `ogar-render-askama`; workspace `cargo check` clean; `cargo clippy + -p ogar-render-askama -- -D warnings` clean. End-to-end verified: a + masked `account.move` (mask={0,2}) renders `struct AccountMove { name, + state }` (dropping field 1 `amount_total`), `fn new(name, state)`, and + `fn action_post(&mut self)` with `CLASS_ID = 0x0202`. Closes the + transpile chain with LEG 1 (ruff #40) + LEG 2 (D-OGAR-ODOO-INHERIT-MIXINS). From 8d02fd4c5e746d828f12ee0a6512e68e9132e695 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:39:25 +0000 Subject: [PATCH 2/2] ogar-from-{ruff,rails}: rev-pin ruff to 61ce2b49 (codex P1 on #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ogar-from-ruff reads ruff_spo_triplet::Model::inherits (added in ruff #40). With no committed Cargo.lock, the floating `branch = "main"` pin could resolve to a ruff rev WITHOUT that field on a clean build and fail to compile — and the prior commit's '61ce2b49' claim didn't travel. Pin all three ruff refs (ruff_spo_triplet + ruff_spo_address in ogar-from-ruff, ruff_ruby_spo in ogar-from-rails) to the exact rev 61ce2b49 (ruff main at the #40 merge). Same rev across both crates: they share ruff_spo_triplet internally, so a mismatch would double-check-out ruff. Verified: ruff re-locks to 61ce2b49; ogar-from-ruff 48 tests + ogar-from-rails green; workspace check + clippy -D warnings clean. --- crates/ogar-from-rails/Cargo.toml | 4 +++- crates/ogar-from-ruff/Cargo.toml | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/ogar-from-rails/Cargo.toml b/crates/ogar-from-rails/Cargo.toml index 9846883..ba6fb40 100644 --- a/crates/ogar-from-rails/Cargo.toml +++ b/crates/ogar-from-rails/Cargo.toml @@ -19,5 +19,7 @@ serde = [ [dependencies] ogar-vocab = { path = "../ogar-vocab" } ogar-from-ruff = { path = "../ogar-from-ruff" } -ruff_ruby_spo = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" } +# Same exact ruff rev as ogar-from-ruff (they share ruff_spo_triplet +# internally; a rev mismatch double-checks-out ruff). Bump in lockstep. +ruff_ruby_spo = { git = "https://github.com/AdaWorldAPI/ruff", rev = "61ce2b490fc3c432d36c44eceed08125f838b405" } serde = { workspace = true, optional = true } diff --git a/crates/ogar-from-ruff/Cargo.toml b/crates/ogar-from-ruff/Cargo.toml index fd00f6c..aa3abe1 100644 --- a/crates/ogar-from-ruff/Cargo.toml +++ b/crates/ogar-from-ruff/Cargo.toml @@ -14,6 +14,13 @@ serde = ["dep:serde", "ogar-vocab/serde"] [dependencies] ogar-vocab = { path = "../ogar-vocab" } -ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" } -ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" } +# ruff pinned to an exact rev (not floating `branch = "main"`): this crate +# reads `ruff_spo_triplet::Model::inherits`, added in ruff PR #40. With no +# committed Cargo.lock, a floating branch could resolve `main` to a rev +# WITHOUT that field and fail to compile. `61ce2b49` is ruff main at the #40 +# merge. Both ruff crates MUST share one rev (they depend on ruff_spo_triplet +# internally; a mismatch double-checks-out ruff). Bump in lockstep with +# ogar-from-rails. +ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", rev = "61ce2b490fc3c432d36c44eceed08125f838b405" } +ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", rev = "61ce2b490fc3c432d36c44eceed08125f838b405" } serde = { workspace = true, optional = true }