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
121 changes: 108 additions & 13 deletions crates/ogar-from-ruff/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,11 @@
//! - `Model::functions` IS now lifted — by [`lift_actions`] (the DO-arm)
//! to a standalone `Vec<ActionDef>`, not onto `Class` (the OGAR `Class`
//! is the THING/THINK shape; actions register on the DO-arm
//! separately). Each method's `reads` / `raises` / `traverses` edges
//! stay on the narrow / SPO arm as triples — `lift_actions` does not
//! duplicate them, nor claim a reactive dependency plain Rails methods
//! don't declare.
//! separately). Each method's `reads` / `writes` / `calls` / `raises`
//! edges ride `ActionDef` as effect annotations; `traverses` still has
//! no `ActionDef` slot and stays on the narrow / SPO arm as triples only
//! — `lift_actions` does not duplicate it, nor claim a reactive
//! dependency plain Rails methods don't declare.
//! - `Model::delegations`, `Model::dsl_calls`, `Model::gem_dsl`,
//! `Model::dynamic_methods`, `Model::refinements` — ruff's
//! long-tail. OGAR doesn't model them yet; can land as
Expand All @@ -70,9 +71,11 @@ pub mod emit;
pub mod mint;
pub mod sqlalchemy; // WS-G-D

use std::collections::HashMap;

use ogar_vocab::{
canonical_concept, ActionDef, Association, AssociationKind, Attribute, Callback, Class,
ComputedField, EnumDecl, EnumSource, Inheritance, Language, Scope, Validation,
ComputedField, EnumDecl, EnumSource, Inheritance, KausalSpec, Language, Scope, Validation,
};
use ruff_spo_triplet::{
AssocDecl, AssocKind, AttrDecl, AttrKind, Callback as RuffCallback, ConcernKind, Model,
Expand Down Expand Up @@ -517,11 +520,15 @@ pub fn project_canonical_roles(class: &Class) -> std::collections::HashSet<&'sta
///
/// This is a **facts-only** lift: it sets the action's `identity`,
/// `predicate` (the method name — already snake_case on the Rails side),
/// `object_class` (the model name), and the `reads` / `writes` / `calls`
/// `object_class` (the model name), the `reads` / `writes` / `calls` / `raises`
/// effect annotations verbatim from [`ruff_spo_triplet::Function`] (OGAR-AS-IR
/// §3 test 2 — "each `ActionDef` declares what it reads, what it writes, what
/// side effects it has… pure-vs-effectful is a type, not a comment"). It
/// deliberately does **not**:
/// side effects it has… pure-vs-effectful is a type, not a comment"), and
/// (SPEC-ATC2-OGAR Arm A) `kausal` — but ONLY when the method is a
/// [`Model::fields`] compute target (`Field::emitted_by == Some(f.name)`),
/// in which case `kausal` becomes `Some(KausalSpec::Depends { paths })`
/// sourced from that field's `Field::depends_on` (the `@api.depends(...)`
/// fact). It deliberately does **not**:
///
/// - set any execution policy (the vocab [`ActionDef`] has no `exec` slot;
/// backend routing is consumer-private — see the OP-arm plan §5.2),
Expand All @@ -530,16 +537,28 @@ pub fn project_canonical_roles(class: &Class) -> std::collections::HashSet<&'sta
/// - populate `kausal` from [`ruff_spo_triplet::Function`]`::reads` — a
/// plain Rails method reading a field is **not** a reactive
/// `@api.depends`-style trigger, so claiming one would leak method-body
/// description into causal semantics. `reads` (and `writes` / `calls`) now
/// ride `ActionDef` as **effect annotations** (see above) — that is a
/// distinct, weaker claim than a `KausalSpec::Depends` reactive trigger,
/// which stays `None` here. `raises` / `traverses` have no `ActionDef`
/// slot yet and still ride the narrow / SPO arm as triples only.
/// description into causal semantics. `reads` (and `writes` / `calls`) ride
/// `ActionDef` as **effect annotations** (see above) — that is a distinct,
/// weaker claim than a `KausalSpec::Depends` reactive trigger, which stays
/// `None` for any method that isn't a known compute target. `traverses`
/// still has no `ActionDef` slot and rides the narrow / SPO arm as triples
/// only.
///
/// The result is registry-ready: guard / RBAC / `exec` enrichment happens
/// downstream at registration, not in the producer.
#[must_use]
pub fn lift_actions(model: &Model) -> Vec<ActionDef> {
// SPEC-ATC2-OGAR Arm A: compute-method name -> its field's @api.depends
// paths. Built from `Model::fields` (Field::emitted_by / depends_on),
// NOT from `reads` — the only provenance-honest source of a reactive
// `KausalSpec::Depends` trigger (see the doc comment above).
let depends_by_method: HashMap<&str, &Vec<String>> = model
.fields
.iter()
.filter_map(|field| field.emitted_by.as_deref().map(|m| (m, &field.depends_on)))
.filter(|(_, paths)| !paths.is_empty())
.collect();

// Public actions AND non-public helpers (AT-CARRY-1b, review on #164):
// since ruff #45 the Ruby walker splits private/protected defs into
// `Model::helpers` — and Rails lifecycle hook targets are conventionally
Expand All @@ -562,6 +581,10 @@ pub fn lift_actions(model: &Model) -> Vec<ActionDef> {
a.reads = f.reads.clone();
a.writes = f.writes.clone();
a.calls = f.calls.clone();
a.raises = f.raises.clone();
if let Some(paths) = depends_by_method.get(f.name.as_str()) {
a.kausal = Some(KausalSpec::depends((*paths).clone()));
}
a
})
.collect()
Expand Down Expand Up @@ -1807,6 +1830,78 @@ mod tests {
assert!(acts[0].calls.is_empty());
}

/// SPEC-ATC2-OGAR Arm A: a method that IS a `Model::fields` compute
/// target (`Field::emitted_by == Some(method_name)`) gets its `kausal`
/// populated from that field's `@api.depends` paths
/// (`Field::depends_on`) — the only provenance-honest source for a
/// reactive `KausalSpec::Depends` trigger.
#[test]
fn lift_actions_depends_field_yields_kausal_depends() {
let mut m = Model::new("SaleOrder");
m.functions.push(Function {
name: "_compute_total".to_string(),
..Default::default()
});
m.fields.push(Field {
name: "amount_total".to_string(),
emitted_by: Some("_compute_total".to_string()),
depends_on: vec!["qty".to_string(), "price".to_string()],
..Default::default()
});
let acts = lift_actions(&m);
assert_eq!(acts.len(), 1);
assert_eq!(
acts[0].kausal,
Some(KausalSpec::depends(vec!["qty".to_string(), "price".to_string()])),
);
}

/// SPEC-ATC2-OGAR Arm A regression (sharpens `lift_actions_is_facts_only`):
/// a method that merely READS fields, without being a compute target
/// (`Field::emitted_by`), must NOT get a fabricated `kausal` — `reads`
/// is an effect annotation, never a causal-dependency source
/// (Provenance-Ehrlichkeit).
#[test]
fn lift_actions_plain_read_still_no_kausal() {
let mut m = Model::new("SaleOrder");
m.functions.push(Function {
name: "log_access".to_string(),
reads: vec!["amount_total".to_string()],
..Default::default()
});
// `amount_total` exists as a field but is NOT computed by
// `log_access` (no `emitted_by` link to this method), so the read
// must stay a plain effect annotation.
m.fields.push(Field {
name: "amount_total".to_string(),
emitted_by: Some("_compute_total".to_string()),
depends_on: vec!["qty".to_string()],
..Default::default()
});
let acts = lift_actions(&m);
assert_eq!(acts.len(), 1);
assert_eq!(acts[0].reads, vec!["amount_total".to_string()]);
assert!(
acts[0].kausal.is_none(),
"a plain field read must not fabricate a KausalSpec::Depends"
);
}

/// SPEC-ATC2-OGAR Arm C: `Function::raises` (already populated by ruff,
/// core-7) now rides the new additive `ActionDef::raises` slot.
#[test]
fn lift_actions_raises_carried_on_actiondef() {
let mut m = Model::new("SaleOrder");
m.functions.push(Function {
name: "action_confirm".to_string(),
raises: vec!["UserError".to_string()],
..Default::default()
});
let acts = lift_actions(&m);
assert_eq!(acts.len(), 1);
assert_eq!(acts[0].raises, vec!["UserError".to_string()]);
}

/// Regression: adding the effect-annotation fields must not disturb the
/// pre-existing identity / predicate / object_class shape — same
/// assertions as `lift_actions_predicate_object_class_and_identity`,
Expand Down
3 changes: 3 additions & 0 deletions crates/ogar-vocab/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ serde = ["dep:serde"]

[dependencies]
serde = { workspace = true, optional = true }

[dev-dependencies]
serde_json = "1.0"
72 changes: 72 additions & 0 deletions crates/ogar-vocab/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,13 @@ pub struct ActionDef {
/// Lifecycle-mutator dispatch CALLS (`Function::calls`, `<receiver>.<method>`).
/// Effect annotation for call-graph analysis; no body is captured.
pub calls: Vec<String>,
/// Exception/error type names this action may RAISE (`ruff_spo_triplet::
/// Function::raises`, e.g. `UserError`, `ValidationError`). Effect
/// annotation against the `exc:` namespace; Authoritative (the `raise`
/// statement names the type). Name-level only — no message/condition.
/// `serde(default)`: additive field, pre-`raises` material deserializes.
#[cfg_attr(feature = "serde", serde(default))]
pub raises: Vec<String>,

// ── Rubicon statem carriers (OGAR-AST-CONTRACT §6) ──
// The three semantics that don't survive Action-flattening; each lowers
Expand Down Expand Up @@ -6005,6 +6012,71 @@ mod tests {
assert_eq!(a.on_enter.as_ref().unwrap().to_value, "sale");
}

#[test]
fn action_def_raises_slot_is_additive_and_defaults_empty() {
// SPEC-ATC2-OGAR Arm C: `raises` is a new additive slot on
// `ActionDef`, mirroring the existing `calls` effect annotation.
// Existing `..Default::default()` construction sites (and
// `ActionDef::new`) must keep compiling with an empty `raises`.
let a = ActionDef::default();
assert!(a.raises.is_empty());
let mut b = ActionDef::new("id", "predicate", "object_class");
assert!(b.raises.is_empty());
b.raises = vec!["UserError".to_string()];
assert_eq!(b.raises, vec!["UserError".to_string()]);
}

/// `serde(default)` guard: pre-`raises` JSON (no `raises` key) must
/// still deserialize — the additive-field convention the vocab uses
/// for every migration-cycle field.
#[cfg(feature = "serde")]
#[test]
fn action_def_raises_missing_key_deserializes() {
let a = ActionDef::new("id", "predicate", "object_class");
let mut json: serde_json::Value = serde_json::to_value(&a).unwrap();
json.as_object_mut().unwrap().remove("raises");
let back: ActionDef = serde_json::from_value(json).unwrap();
assert!(back.raises.is_empty());
}

#[test]
fn kausal_spec_match_is_exhaustive() {
// SPEC-ATC2-OGAR §7 governance test: an exhaustive match (no
// wildcard arm) over every `KausalSpec` variant. `KausalSpec` is
// `#[non_exhaustive]` for DOWNSTREAM crates only — within the
// defining crate a wildcard-free match is still exhaustiveness
// checked by rustc, so this test fails to COMPILE the moment a
// new variant (e.g. `Constrains` / `Onchange` from Arm B) is added
// without a matching arm here. That loud compile break is the
// COUNT_FUSE substitute the doctrine asks for in place of leaning
// on `#[non_exhaustive]` alone.
let specs = vec![
KausalSpec::StateGuard {
guard_field: "state".into(),
guard_values: vec!["draft".into()],
},
KausalSpec::LifecycleTrigger {
event: "before_save".into(),
},
KausalSpec::Depends {
paths: vec!["a.b".into()],
},
KausalSpec::ContextDepends {
keys: vec!["lang".into()],
},
KausalSpec::External,
];
for spec in specs {
match spec {
KausalSpec::StateGuard { .. } => {}
KausalSpec::LifecycleTrigger { .. } => {}
KausalSpec::Depends { .. } => {}
KausalSpec::ContextDepends { .. } => {}
KausalSpec::External => {}
}
}
}

// ── all_promoted_classes() — enumerator pinned to class_ids::ALL ──

#[test]
Expand Down
22 changes: 22 additions & 0 deletions docs/DISCOVERY-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1061,3 +1061,25 @@ isolation. The map's job is to keep them visible.
lockstep left as a separate, named follow-up.

- **D-NEVER-PIN-BUMP** — 2026-07-06 operator ruling `[G]`: *"wir machen NIEMALS pin bump."* Every cross-repo dep floats on `branch = "main"`; the drift protection is loud compile breaks + fuses + fix-forward (proven same-day: ruff #45 `guarded_writes` → OGAR #162 within minutes, twice). Consequently the #45-post-merge-audit nit "make `ruff_spo_triplet::{Function, Model}` `#[non_exhaustive]`" is **REJECTED by ruling** — it would erase the loud-break signal and tax every construction site org-wide forever (non_exhaustive forbids struct literals even with `..Default::default()` outside the defining crate, including ruff's own frontend crates). The standing pattern stays: downstream fixtures construct with `..Default::default()` (established in #162); a rustdoc note on `ruff_spo_triplet::{Function, Model}` documenting this construction convention is a named follow-up (no companion PR yet).

- **D-ATC2-KAUSAL-AUTARK** — 2026-07-06 `[G]`: AT-CARRY-2 arms A+C landed
self-contained (no ruff prerequisite). Arm A: `lift_actions` populates
`kausal = KausalSpec::Depends` from the `Field::emitted_by →
Field::depends_on` index ONLY — never fabricated from `reads`
(provenance honesty, regression `lift_actions_plain_read_still_no_kausal`
proves a read of a computed field does not leak kausal onto the reader).
Arm C: additive `ActionDef.raises` slot (`Function::raises`,
Authoritative), `serde(default)` per the vocab's additive-field
convention + missing-key round-trip test. The odoo-rs triangulation
claimed the whole KausalSpec chain was ruff-gated; primary-source
verification narrowed it: only arms B (constrains/onchange variants)
+ D (`computed.stored`) need ruff — landing there as ruff #49, OGAR
follow-up wires them AFTER ruff main carries the fields (float-on-main,
D-NEVER-PIN-BUMP; the interim loud break is the design). Guard:
`kausal_spec_match_is_exhaustive` — a wildcard-free intra-crate match
that fails to COMPILE when arm B adds variants without updating
consumers. Named assumption (untested, spec-conform): two fields
sharing one `emitted_by` compute method resolve last-wins in the
index — safe under Odoo semantics (`@api.depends` is method-level,
co-computed fields carry identical `depends_on`), revisit if a
frontend ever emits divergent `depends_on` per co-computed field.
Loading