-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlib.rs
More file actions
2159 lines (2044 loc) · 98.7 KB
/
Copy pathlib.rs
File metadata and controls
2159 lines (2044 loc) · 98.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! `ogar-from-ruff` — lift `ruff_spo_triplet::Model` into `ogar_vocab::Class`.
//!
//! The ruff IR (`ruff_spo_triplet::Model` populated by `ruff_ruby_spo` and
//! future `ruff_python_spo` / `ruff_elixir_spo` frontends) was
//! deliberately shaped to mirror `ogar_vocab::Class` (see
//! `ogar-vocab/src/lib.rs`:14: "The types deliberately mirror the
//! C17a–c stable shape in `ruff_ruby_spo` so the existing producer can
//! be lifted in-place"). This crate is the mechanical projection that
//! does that lift in-place.
//!
//! # Layer position
//!
//! ```text
//! source tree → ruff_ruby_spo::extract → ruff_spo_triplet::ModelGraph
//! │
//! this crate ─┤ lift_model_graph / lift_model
//! ▼
//! ogar_vocab::Class
//! │
//! ogar-from-rails (or callers)
//! ▼
//! lance-graph-ontology::OntologyRegistry
//! ```
//!
//! # Field map (the part of the contract that matters)
//!
//! | ruff (Model) | ogar (Class) | notes |
//! |------------------------|-------------------------------|---------------------------------------------|
//! | `name` | `name` | verbatim |
//! | `associations` | `associations` | rich option parsing per [`lift_association`]; `AcceptsNestedAttributesFor` skipped (UI form helper, not a relation) |
//! | `validations` | `validations` | flatten kind + target + options into `rule_source` |
//! | `callbacks` | `callbacks` | `phase` → `event`, `target` → `target_method` |
//! | `concerns` | `mixins` | `IncludesModule` / `ExtendsModule` / `PrependsModule` lift their target; block markers (`ConcernClassMethods` / `ConcernIncludedBlock`) skipped |
//! | `attributes` | `attributes` | the option `"type"` lifts to `Attribute.type_name` |
//! | `scopes` | `scopes` / `default_scope` | `Scope` / `Scopes` → `Class.scopes`; `DefaultScope` → `Class.default_scope` |
//! | `acts_as` | `mixins` | rendered as `acts_as_<variant>` so they survive on the same shelf as concerns (`ogar-vocab` has no separate `acts_as` slot) |
//! | `sti.inherits_from` | `parent` | STI parent — matches `Class.parent` slot |
//! | `inherits` | `mixins` (appended) | Odoo `_inherit` multi-parent mixin composition — the vocab's `mixins` doc names `_inherit`; the `inheritance` axis excludes mixins. Frontend-agnostic field, populated only by the Odoo frontend |
//! | `functions` | `Vec<ActionDef>` (DO-arm) | [`lift_actions`] — one `ActionDef` per method; standalone, not on `Class` |
//! | `fields` | `attributes` / `associations` / `computed_fields` | the D-AR-3.5 physical schema stratum (migration-DSL columns for Rails, and `db.Column(...)` for Flask-SQLAlchemy, both via [`project_total_schema_fields`]; `fields.X(...)` declarations for Odoo via [`project_odoo_fields`]); `not_null` wires `AttributeOptions::required` (Rails/SQLAlchemy only); a `<name>_id` FK column is skipped when the model also declares an association named `<name>` (double-strata dedup) |
//!
//! Fields NOT lifted today (no equivalent on the ruff side OR no clean
//! semantic mapping):
//!
//! - `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` / `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
//! extension prefixes (`ogar-extensions/rails/...`) later.
//!
//! # What this crate does NOT do
//!
//! - **Source I/O**: it operates on already-extracted `Model` /
//! `ModelGraph` values. `ogar-from-rails` adds the file-walk +
//! `ruff_ruby_spo::extract` invocation.
//! - **Ontology registration**: handing the resulting `Class` off to
//! `lance-graph-ontology::OntologyRegistry` is a separate step.
//! This crate's output is registry-ready, not registry-bound.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
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, KausalSpec, Language, Scope, Validation,
};
use ruff_spo_triplet::{
AssocDecl, AssocKind, AttrDecl, AttrKind, Callback as RuffCallback, ConcernKind, Model,
ModelGraph, ScopeDecl, ScopeKind, StiInfo, Validation as RuffValidation, ValidationKind,
};
/// Lift every model in a [`ModelGraph`] to an OGAR [`Class`] (Rails /
/// `ruff_ruby_spo` producer — [`Language::Ruby`]). Output preserves
/// declaration order so downstream consumers can rely on deterministic
/// ordering for snapshot tests.
#[must_use]
pub fn lift_model_graph(graph: &ModelGraph) -> Vec<Class> {
lift_model_graph_with_language(graph, Language::Ruby)
}
/// Lift a whole [`ModelGraph`] for a Python / Odoo producer
/// (`ruff_python_spo`) — like [`lift_model_graph`] but stamps each class as
/// [`Language::Python`]. The `odoo` namespace already routes to the `erp`
/// source domain via the same `classify_domain` path.
#[must_use]
pub fn lift_model_graph_python(graph: &ModelGraph) -> Vec<Class> {
lift_model_graph_with_language(graph, Language::Python)
}
fn lift_model_graph_with_language(graph: &ModelGraph, language: Language) -> Vec<Class> {
let domain = classify_domain(&graph.namespace);
let concept_domain = domain.as_deref().and_then(ogar_vocab::source_domain_concept);
// The harvest namespace IS the curator id (`"openproject"`,
// `"redmine"`, `"odoo"`, …). `source_domain` is the coarse bucket it
// maps to; `source_curator` keeps the specific product so two curators
// in the same domain (Redmine + OpenProject are both `project`) stay
// distinguishable downstream.
let curator = if graph.namespace.is_empty() {
None
} else {
Some(graph.namespace.clone())
};
graph
.models
.iter()
.map(|m| {
let mut class = lift_model_with_language(m, language);
class.source_domain = domain.clone();
class.source_curator = curator.clone();
// Domain-gate the canonical concept. `lift_model` resolves
// domain-blind (an all-domains best guess); here we know the
// curator's domain, so re-resolve through the gate to withhold a
// promotion whose codebook domain doesn't match — a generic
// `Role` in a non-project curator must stay `role`, not become
// `project_role` (codex P2 on #72). Cross-domain bridges like
// `billable_work_entry` are exempt and still converge.
class.canonical_concept =
Some(ogar_vocab::canonical_concept_in_domain(&m.name, concept_domain));
class
})
.collect()
}
/// Name the curator **domain** from the harvest namespace — the "one tiny
/// regex" that tags OpenProject as a `project` domain and Odoo as an `erp`
/// domain. A coarse, curator-agnostic label (a `ClassFingerprint`
/// component), not the namespace itself. Returns `None` for unrecognized
/// namespaces — the domain stays unset rather than guessed.
fn classify_domain(namespace: &str) -> Option<String> {
let ns = namespace.to_ascii_lowercase();
if ns.contains("openproject") || ns.contains("redmine") {
Some("project".to_string())
} else if ns.contains("odoo") {
Some("erp".to_string())
} else if ns.contains("woa") || ns.contains("smb") {
// WoA-rs / SMB — the German-ERP sanity witness adapter.
Some("german-erp".to_string())
} else {
None
}
}
/// Lift one [`Model`] to an OGAR [`Class`] stamped as [`Language::Ruby`]
/// (the Rails / `ruff_ruby_spo` producer). Pure projection — no I/O.
///
/// For the Python / Odoo producer (`ruff_python_spo`) use
/// [`lift_model_python`]. Both delegate to the same projection and differ
/// only in the language discriminant. Language is set explicitly rather
/// than guessed from `ModelGraph::namespace`, because the namespace
/// (`openproject`, `odoo`, …) doesn't bind to the producer language
/// one-to-one.
#[must_use]
pub fn lift_model(model: &Model) -> Class {
lift_model_with_language(model, Language::Ruby)
}
/// Lift one [`Model`] to an OGAR [`Class`] stamped as [`Language::Python`]
/// — the Odoo / Django producer path (`ruff_python_spo`). Identical
/// projection to [`lift_model`]; only the language discriminant differs.
#[must_use]
pub fn lift_model_python(model: &Model) -> Class {
lift_model_with_language(model, Language::Python)
}
fn lift_model_with_language(model: &Model, language: Language) -> Class {
let mut class = Class::new(&model.name);
class.language = language;
class.parent = model.sti.as_ref().and_then(sti_parent);
class.inheritance = lift_inheritance(model);
class.canonical_concept = Some(canonical_concept(&model.name));
class.associations = model.associations.iter().filter_map(lift_association).collect();
class.mixins = lift_mixins(model);
// Odoo `_inherit` (multi-parent mixin composition) lands on the same
// mixins shelf the vocab designates for it — `Class::mixins` doc names
// `_inherit = 'mixin.thread'`, and `Class::inheritance` explicitly
// excludes mixins ("Mixins / concerns are a SEPARATE axis"). The ruff
// frontend already normalised the names (dot→underscore→verbatim),
// deduped, and excluded the bare-`_inherit` reopen self-edge. Only the
// Odoo frontend populates `Model::inherits`, so this is a no-op for the
// Rails (`sti`) and C++ (`bases`) producers — hence unconditional.
class.mixins.extend(model.inherits.iter().cloned());
class.attributes = model.attributes.iter().filter_map(lift_attribute).collect();
class.enums = model.attributes.iter().filter_map(lift_enum).collect();
class.scopes = model.scopes.iter().filter_map(lift_scope).collect();
class.scope_predeclarations = lift_scope_predeclarations(model);
class.callbacks = model.callbacks.iter().map(lift_callback).collect();
class.validations = model.validations.iter().filter_map(lift_validation).collect();
class.default_scope = lift_default_scope(model);
// Rails carries its DECLARED schema in the AR-DSL vectors lifted above
// (`attribute :x, :type`, `belongs_to :y`); an Odoo model instead
// declares everything as `fields.X(...)`, which lands in the core-7
// `Model::fields` vector. Both frontends ALSO populate `Model::fields`
// with the D-AR-3.5 *physical* schema stratum (Rails: the migration DSL
// columns via `ruff_ruby_spo::extract_app_with_schema`; Odoo: the same
// vector doubles as the declared schema since Odoo has no separate
// AR-DSL). So each language gets its own field-projection pass —
// `project_odoo_fields` for Python/Odoo (Codex P1, PR #131), and
// `project_total_schema_fields` for Ruby (falsifier #1 gap-close,
// D-PARITY-PROBE-WP-1; also shared by the SQLAlchemy producer, see
// `sqlalchemy.rs`) — so neither producer silently drops the schema
// stratum lifted by its frontend.
match language {
Language::Python => project_odoo_fields(&mut class, model),
Language::Ruby => project_total_schema_fields(&mut class, model),
_ => {}
}
class
}
/// Project an Odoo model's core-7 [`Model::fields`] onto the
/// schema-carrying [`Class`] columns. Python-only: Rails models keep their
/// schema in `model.attributes` / `model.associations` (lifted separately),
/// and ALSO populate `model.fields` (DB columns), so projecting fields for
/// Rails would double-count. Odoo leaves the AR vectors empty and puts
/// everything in `fields`.
///
/// Per-field mapping:
/// - relational field (`target` set) → [`Association`]; the kind comes from
/// the field's cardinality (`relation_kind`), `class_name` is the raw
/// comodel, `inverse_of` the One2many inverse.
/// - non-relational field → [`Attribute`] with `type_name` set from the SPO
/// `Field`'s `field_type` (the lowercased Odoo constructor — `char` /
/// `integer` / `monetary` / …), so the emitters pick a concrete wrapper type
/// instead of the untyped `OgScalar` fallback.
/// - compute field (`emitted_by` set) → [`ComputedField`] (method +
/// `@api.depends`), in addition to its Attribute / Association above.
fn project_odoo_fields(class: &mut Class, model: &Model) {
for field in &model.fields {
if let Some(comodel) = &field.target {
let kind = odoo_relation_kind(field.relation_kind.as_deref(), field.inverse_name.is_some());
let mut assoc = Association::new(kind, &field.name);
assoc.class_name = Some(comodel.clone());
assoc.inverse_of = field.inverse_name.clone();
class.associations.push(assoc);
} else {
let mut attr = Attribute::new(&field.name);
// Carry the Odoo constructor (field_type) so the emitters can pick a
// concrete wrapper type (OgStr/OgInt/OgMoney/…) instead of the
// untyped OgScalar fallback. None for a field whose type ruff did
// not capture → OgScalar (the safe default).
attr.type_name = field.field_type.clone();
class.attributes.push(attr);
}
if let Some(compute_method) = &field.emitted_by {
let mut computed = ComputedField::new(&field.name, compute_method);
computed.depends = field.depends_on.clone();
// SPEC-ATC2-OGAR Arm D: `store=` kwarg fact (ruff main,
// `Field::stored: Option<bool>`). `None` (no `store=` kwarg) and
// `Some(false)` both mean "not stored" (Odoo's own default for
// computed fields); only `Some(true)` sets it.
computed.stored = field.stored.unwrap_or(false);
class.computed_fields.push(computed);
}
}
}
/// Project a total-nullability model's D-AR-3.5 schema stratum — the
/// PHYSICAL DB columns in `Model::fields` — onto the schema-carrying
/// [`Class`] columns. Shared by two producers:
/// - **Rails** (Ruby): populated by `ruff_ruby_spo::extract_app_with_schema`
/// from the `db/migrate/tables/*.rb` migration DSL. Counterpart of
/// [`project_odoo_fields`]: the AR-DSL vectors (`attributes` /
/// `associations`, from `attribute :x, :type` / `belongs_to :y`) carry the
/// *declared* schema and are lifted separately, earlier in
/// [`lift_model_with_language`]; `fields` carries the *physical* schema
/// (what the migration actually created). Both strata are real and both
/// must reach the [`Class`], or the Rails lift silently drops the physical
/// column set the way the pre-fix Python-only gate did (falsifier #1 /
/// D-PARITY-PROBE-WP-1).
/// - **Flask-SQLAlchemy** (Python/WoA): populated via
/// [`crate::sqlalchemy::lift_model_sqlalchemy`]'s `db.Column(...)`
/// declarations, which are likewise total (every column's nullability is a
/// declared fact) — see that module's doc for why it routes `Language::Python`
/// through this helper instead of [`project_odoo_fields`].
///
/// Per-field mapping (mirrors [`project_odoo_fields`]):
/// - relational field (`target` set) → [`Association`]; same shape as the
/// Odoo path. `ruff_ruby_spo::schema` never sets `target` today (the
/// migration DSL has no relation-aware column form), so this arm is
/// dormant for Rails until a future relation-aware schema pass — kept
/// for shape-parity with Odoo rather than speculative dead code.
/// - scalar field named `<name>_id` where the model ALSO declares an AR-DSL
/// association named `<name>` (already lifted onto `class.associations`
/// earlier in [`lift_model_with_language`]) → **skipped**. The FK column
/// (physical stratum) and the declared `belongs_to`/`has_many` (declared
/// stratum) are the SAME relation seen twice; projecting both would
/// double-report it as `<name>_id: OgInt` (the ORM spelling) AND
/// `<name>: ToOne<X>` (the AR spelling) on the same [`Class`]. The canon
/// keeps the AR spelling — see [`is_fk_shadowed_by_association`]. The
/// literal primary key `id` never matches this pattern (it carries no
/// `_id`-suffixed prefix of its own) and is always kept.
/// - other scalar field → [`Attribute`] with `type_name` from
/// `Field::field_type` (the migration DSL type token verbatim: `string`,
/// `bigint`, `integer`, …), and `AttributeOptions::required` wired from
/// `Field::not_null`: `Some(true)` (`null: false` in the migration) maps
/// to `Some(true)`; `None` (nullable — Rails' column default, and the only
/// other value `not_null` carries per its own doc) maps to `Some(false)`.
/// The schema stratum is total knowledge here — every column has a real
/// nullability, so absence of `null: false` IS a positive "nullable" fact,
/// not "unknown" the way an Odoo `required=` kwarg's absence would be.
/// - compute field (`emitted_by` set — the D-AR-3.5 compute-linkage pass,
/// `ruff_ruby_spo::schema::link_computed_fields`) → [`ComputedField`],
/// same as the Odoo path.
pub(crate) fn project_total_schema_fields(class: &mut Class, model: &Model) {
for field in &model.fields {
if let Some(comodel) = &field.target {
let kind = odoo_relation_kind(field.relation_kind.as_deref(), field.inverse_name.is_some());
let mut assoc = Association::new(kind, &field.name);
assoc.class_name = Some(comodel.clone());
assoc.inverse_of = field.inverse_name.clone();
class.associations.push(assoc);
} else if !is_fk_shadowed_by_association(&field.name, class)
&& !class.attributes.iter().any(|a| a.name == field.name)
{
// The second guard (no already-lifted attribute of the same
// name) closes PR #156 finding (c): a model that both declares
// `attribute :foo, :string` (AR-DSL, lifted earlier in
// `lift_model_with_language`) AND has a physical `foo` column
// must not end up with two `foo` struct fields. On collision,
// the AR-DSL declaration wins (it is the declared truth; the
// physical column is the same field seen from the migration) —
// we skip the physical duplicate rather than merge/overwrite,
// since overwriting would change `type_name` provenance and
// risk drift. Type reconciliation on collision is a follow-up,
// not done here.
let mut attr = Attribute::new(&field.name);
attr.type_name = field.field_type.clone();
// Schema stratum is total: null:false -> required; the Rails
// default (nullable, `not_null == None`) -> explicitly optional.
attr.options.required = Some(field.not_null.unwrap_or(false));
class.attributes.push(attr);
}
if let Some(compute_method) = &field.emitted_by {
let mut computed = ComputedField::new(&field.name, compute_method);
computed.depends = field.depends_on.clone();
// SPEC-ATC2-OGAR Arm D — same `store=` wiring as
// `project_odoo_fields` above.
computed.stored = field.stored.unwrap_or(false);
class.computed_fields.push(computed);
}
}
}
/// FK-dedup predicate for [`project_total_schema_fields`]. Two independent
/// shadow rules, checked in order:
///
/// 1. **Explicit `foreign_key:`** — `class` carries an [`Association`] whose
/// declared `foreign_key` equals `field_name` verbatim (e.g.
/// `belongs_to :author, foreign_key: "user_id"` shadows the physical
/// `user_id` column even though the association's own name is `author`,
/// not `user`). This is the authoritative signal (PR #156 finding (b)) —
/// checked first.
/// 2. **`<name>_id` naming convention** — `field_name` has the shape
/// `<name>_id` for a non-empty `<name>`, AND `class` already carries an
/// [`Association`] named `<name>` (from the AR-DSL vector, lifted before
/// this function runs in [`lift_model_with_language`]). The bare `id`
/// primary key column never matches either rule — `"id"` has no
/// `"_id"`-suffixed prefix of its own, and no real corpus association
/// declares `foreign_key: "id"` — and is always kept as a scalar
/// attribute.
pub(crate) fn is_fk_shadowed_by_association(field_name: &str, class: &Class) -> bool {
if class
.associations
.iter()
.any(|a| a.foreign_key.as_deref() == Some(field_name))
{
return true;
}
field_name
.strip_suffix("_id")
.filter(|prefix| !prefix.is_empty())
.is_some_and(|prefix| class.associations.iter().any(|a| a.name == prefix))
}
/// Map an Odoo relation cardinality to the canonical [`AssociationKind`].
///
/// `relation_kind` (`many2one` / `one2many` / `many2many`, from ruff's
/// `relation_kind` predicate) is the authoritative signal. The
/// `has_inverse` fallback covers the theoretical case of a relational field
/// with no recorded cardinality: an inverse implies a One2many, otherwise
/// the to-one default `BelongsTo`. `target` + `inverse_name` alone cannot
/// separate a Many2one from a Many2many — both are comodel-only with no
/// inverse — which is exactly why `relation_kind` exists.
fn odoo_relation_kind(relation_kind: Option<&str>, has_inverse: bool) -> AssociationKind {
match relation_kind {
Some("many2one") => AssociationKind::BelongsTo,
Some("one2many") => AssociationKind::HasMany,
Some("many2many") => AssociationKind::HasAndBelongsToMany,
_ if has_inverse => AssociationKind::HasMany,
_ => AssociationKind::BelongsTo,
}
}
// ───────────────────────── ProjectWorkItem role projection ─────────────
//
// Curator-side mapping: project_work_item's canonical roles vs the
// Rails-AR-dialect names that surface them. Used by the lineage-transcode
// smoke (Redmine Issue <-> OpenProject WorkPackage through the canonical
// bridge) — both curators must project losslessly onto the same role set.
//
// Synonyms are deliberate and minimal: only well-known Rails-AR variants
// of the canonical role appear here. Adding a new synonym is the right
// move when a real corpus surfaces it; inventing synonyms isn't.
/// Map a Rails-curator association name to the canonical
/// [`ogar_vocab::project_work_item`] role it realises. Returns `None`
/// when the association is not part of the project-work-item canonical
/// surface (e.g. Redmine `fixed_version`, OP `file_links`).
#[must_use]
pub fn project_work_item_role(curator_name: &str) -> Option<&'static str> {
match curator_name {
"project" => Some("project"),
"status" => Some("status"),
"tracker" | "type" => Some("type"),
"priority" => Some("priority"),
"author" => Some("author"),
"assigned_to" | "assignee" | "responsible" => Some("assignee"),
"journals" => Some("journals"),
"relations" | "relations_from" | "relations_to" => Some("relations"),
"time_entries" => Some("time_entries"),
_ => None,
}
}
/// Map a mixin / `acts_as_*` name to a canonical
/// [`ogar_vocab::project_work_item`] role it provides indirectly. OP's
/// WorkPackage carries `journals` via `acts_as_journalized` and
/// `relations` via the `WorkPackages::Relations` concern instead of
/// direct `has_many` calls; this resolver recovers them so the canonical
/// projection is total.
#[must_use]
pub fn project_work_item_role_from_mixin(mixin: &str) -> Option<&'static str> {
if mixin == "acts_as_journalized" || mixin.ends_with("::Journalized") {
return Some("journals");
}
if mixin == "WorkPackages::Relations" || mixin.ends_with("::Relations") {
return Some("relations");
}
None
}
/// The set of canonical [`ogar_vocab::project_work_item`] roles a curator
/// class projects onto. Unions association-derived roles
/// ([`project_work_item_role`]) with mixin-derived roles
/// ([`project_work_item_role_from_mixin`]). Returns the empty set when
/// the class has no project-work-item shape.
#[must_use]
pub fn project_work_item_canonical_roles(
class: &Class,
) -> std::collections::HashSet<&'static str> {
let mut set = std::collections::HashSet::new();
for a in &class.associations {
if let Some(role) = project_work_item_role(&a.name) {
set.insert(role);
}
}
for m in &class.mixins {
if let Some(role) = project_work_item_role_from_mixin(m) {
set.insert(role);
}
}
set
}
// ───────────────────────── Project role projection ─────────────────────
//
// The Project canonical class (ogar_vocab::project) has 4 family edges:
// parent, work_items, time_entries, members. Curators surface them under
// varying Rails-AR names — Redmine `issues` vs OP `work_packages` for the
// work-item set; both spell `members`/`users`/`memberships` for the actor
// set. This resolver normalises curator names onto the canonical roles
// so the lineage-transcode claim holds for `Project` too.
/// Map a Rails-curator association name to the canonical
/// [`ogar_vocab::project`] role it realises. Returns `None` when the
/// association is not part of the project canonical surface (e.g. Redmine
/// `news`, OP `forums` — real but not yet promoted into the canonical
/// shape; future PRs may extend if cross-curator evidence accumulates).
#[must_use]
pub fn project_role(curator_name: &str) -> Option<&'static str> {
match curator_name {
"parent" => Some("parent"),
"issues" | "work_packages" => Some("work_items"),
"time_entries" => Some("time_entries"),
// The actor set is reached via multiple AR through-associations
// in both curators: members / memberships / users (Redmine + OP) /
// member_principals / principals (OP only — OP adds the
// through-Principal hop).
"members" | "memberships" | "users" | "member_principals" | "principals" => {
Some("members")
}
_ => None,
}
}
/// The set of canonical [`ogar_vocab::project`] roles a curator class
/// projects onto. Pure association-derived for v1 — `project` carries no
/// mixin-borne roles in either Redmine or OP today (the mixin layer
/// only contributes to `project_work_item`'s journals / relations).
#[must_use]
pub fn project_canonical_roles(class: &Class) -> std::collections::HashSet<&'static str> {
let mut set = std::collections::HashSet::new();
for a in &class.associations {
if let Some(role) = project_role(&a.name) {
set.insert(role);
}
}
set
}
// ───────────────────────────── actions (DO-arm) ─────────────────────────
/// Lift a [`Model`]'s methods to OGAR [`ActionDef`] declarations — the
/// DO-arm of the harvest. One `ActionDef` per [`ruff_spo_triplet::Function`].
///
/// 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), 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"), 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). Failing that (Arm B, `ruff` main): `@api.constrains(...)` /
/// `@api.onchange(...)` decorator facts (`Function::constrains` /
/// `Function::onchange`) become `KausalSpec::Constrains` / `KausalSpec::
/// Onchange` respectively — a validation / UI-recompute trigger, never
/// conflated with the persisted `Depends` recompute trigger — and the
/// matching decorator name (`"api.constrains"` / `"api.onchange"`) is
/// recorded on `decorators`. 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),
/// - construct an `ActionInvocation` (a live-instance carrier: no target
/// instance / cycle / trace exists at harvest time),
/// - 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`) 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
// private (Redmine measurement: 67/84 hook targets live there). The W3.3
// delete-blocking rows ARE those hook bodies, so the DO-arm must carry
// both. Consistent with this fn's contract: the producer emits effect
// annotations; routability / guard / RBAC enrichment happens downstream
// at registration (a registrar can re-join `Model::callbacks` by name to
// tell hook targets from routable actions).
model
.functions
.iter()
.chain(model.helpers.iter())
.map(|f| {
let mut a = ActionDef::new(
format!("{}::action_def::{}", model.name, f.name),
&f.name,
&model.name,
);
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()));
}
// SPEC-ATC2-OGAR Arm B: `@api.constrains` / `@api.onchange`
// decorator facts (`Function::constrains` / `Function::onchange`,
// ruff main). Only applied when Arm A left `kausal` unset — a
// persisted `@api.depends` recompute trigger outranks a
// validation/UI-recompute trigger on the same method (the spec's
// conflict rule; a method cannot be two kinds of trigger at once
// in this sum type, and `Depends` is the stronger/persisted
// claim). `constrains` is checked before `onchange` so a method
// that (unusually) carries both decorators keeps the validation
// reading, which is the narrower/more consequential of the two.
if a.kausal.is_none() && !f.constrains.is_empty() {
a.kausal = Some(KausalSpec::Constrains {
paths: f.constrains.clone(),
});
a.decorators.push("api.constrains".to_string());
} else if a.kausal.is_none() && !f.onchange.is_empty() {
a.kausal = Some(KausalSpec::Onchange {
paths: f.onchange.clone(),
});
a.decorators.push("api.onchange".to_string());
}
a
})
.collect()
}
// ───────────────────────────── associations ──────────────────────────────
/// Lift one [`AssocDecl`] to an OGAR [`Association`].
///
/// Returns `None` for [`AssocKind::AcceptsNestedAttributesFor`] —
/// Rails' `accepts_nested_attributes_for :foo` is a UI form helper,
/// not a relation. The ogar [`AssociationKind`] enum has no variant
/// for it; skipping is correct.
#[must_use]
pub fn lift_association(a: &AssocDecl) -> Option<Association> {
let kind = match a.kind {
AssocKind::BelongsTo => AssociationKind::BelongsTo,
AssocKind::HasOne => AssociationKind::HasOne,
AssocKind::HasMany => AssociationKind::HasMany,
AssocKind::HasAndBelongsToMany => AssociationKind::HasAndBelongsToMany,
AssocKind::AcceptsNestedAttributesFor => return None,
};
let mut out = Association::new(kind, &a.name);
for (k, v) in &a.options {
let trimmed = strip_ruby_literal_markers(v);
match k.as_str() {
"class_name" => out.class_name = Some(trimmed.to_string()),
"foreign_key" => out.foreign_key = Some(trimmed.to_string()),
"polymorphic" => out.polymorphic = parse_bool(trimmed),
"through" => out.through = Some(trimmed.to_string()),
"source" => out.source = Some(trimmed.to_string()),
"as" => out.as_target = Some(trimmed.to_string()),
"dependent" => out.dependent = Some(trimmed.to_string()),
"optional" => out.optional = parse_bool(trimmed),
"inverse_of" => out.inverse_of = Some(trimmed.to_string()),
"before_add" => out.before_add = Some(trimmed.to_string()),
"after_add" => out.after_add = Some(trimmed.to_string()),
"before_remove" => out.before_remove = Some(trimmed.to_string()),
"after_remove" => out.after_remove = Some(trimmed.to_string()),
// Unknown options pass through silently — forward-compat
// for ORM extensions Rails ships later.
_ => {}
}
}
Some(out)
}
// ───────────────────────────── mixins ───────────────────────────────────
fn lift_mixins(model: &Model) -> Vec<String> {
let mut out = Vec::new();
for c in &model.concerns {
// Block markers (`class_methods do` / `included do`) carry no
// target; skip them. Real `include` / `extend` / `prepend`
// declarations lift to the mixin name verbatim.
match c.kind {
ConcernKind::Include | ConcernKind::Extend | ConcernKind::Prepend => {
out.push(c.module.clone());
}
ConcernKind::ClassMethodsBlock | ConcernKind::IncludedBlock => {}
}
}
for aa in &model.acts_as {
// Render `acts_as_<variant>` so consumers can pattern-match
// on the prefix. Inline options drop to keep the mixin string
// bounded (full options survive on the ruff side).
out.push(format!("acts_as_{}", aa.variant));
}
out
}
// ───────────────────────────── attributes ───────────────────────────────
fn lift_attribute(a: &AttrDecl) -> Option<Attribute> {
// Only "real" attribute-bearing kinds project; alias / undef /
// store_accessor are different shelves (the consumer doesn't
// benefit from squashing them all into Attribute). `Enum` is
// intentionally excluded — it has its own `Class.enums` slot
// populated by [`lift_enum`].
let kept = matches!(
a.kind,
AttrKind::Attribute
| AttrKind::AttrAccessor
| AttrKind::AttrReader
| AttrKind::AttrReadonly
| AttrKind::StoreAttribute
| AttrKind::Serialize
| AttrKind::DefineAttributeMethod
);
if !kept {
return None;
}
let mut out = Attribute::new(&a.name);
out.type_name = a
.options
.iter()
.find_map(|(k, v)| (k == "type" && !v.is_empty()).then(|| v.clone()));
Some(out)
}
/// Lift a Rails `enum :status, { open: 0, closed: 1 }` declaration
/// to an OGAR [`EnumDecl`]. Returns `None` for non-Enum [`AttrDecl`]
/// kinds.
///
/// `ruff_ruby_spo::walk` drops the variant-list hash on the floor
/// (see the walker comment: "`enum :status, { active: 0 }` — 1 attr
/// (skip Hash)"), so the lifted `EnumDecl::source` is always
/// `EnumSource::Static(empty)` for Rails today. The column name is
/// still useful — downstream consumers know "this column is
/// enum-backed", even without the variant list. A future
/// `ruff_ruby_spo` enrichment could pass the variants through
/// `AttrDecl::options` and this lift would extend.
fn lift_enum(a: &AttrDecl) -> Option<EnumDecl> {
if !matches!(a.kind, AttrKind::Enum) {
return None;
}
let mut out = EnumDecl::new(&a.name);
out.source = EnumSource::Static(Vec::new());
Some(out)
}
// ───────────────────────────── scopes / default_scope ───────────────────
fn lift_scope(s: &ScopeDecl) -> Option<Scope> {
// `Scope` only — the singular form has a body. `DefaultScope`
// lives on a separate `Class.default_scope` slot. `Scopes`
// (OpenProject's plural-list DSL `scopes :a, :b, :c`) is a
// name-only predeclaration with no body; codex P2 on #52 flagged
// that lifting it as a body-less `Scope` produces bogus
// body-empty scope records — it should land in
// `Class.scope_predeclarations` instead (handled by
// [`lift_scope_predeclarations`]).
if !matches!(s.kind, ScopeKind::Scope) {
return None;
}
Some(Scope::new(&s.name, &s.body_ref))
}
/// Lift OpenProject's plural-list `scopes :a, :b, :c` DSL to OGAR's
/// `Class.scope_predeclarations` slot — a name-only predeclaration of
/// scope methods defined elsewhere (typically a mixin's `included`
/// block). Distinct from `Class.scopes` (which carries the lambda
/// body) per codex P2 on #52.
fn lift_scope_predeclarations(model: &Model) -> Vec<String> {
model
.scopes
.iter()
.filter(|s| matches!(s.kind, ScopeKind::Scopes))
.map(|s| s.name.clone())
.collect()
}
fn lift_default_scope(model: &Model) -> Option<String> {
model
.scopes
.iter()
.find(|s| matches!(s.kind, ScopeKind::DefaultScope))
.map(|s| s.body_ref.clone())
}
// ───────────────────────────── callbacks ────────────────────────────────
fn lift_callback(cb: &RuffCallback) -> Callback {
// Block-form callbacks (`before_save { ... }` / `do ... end`)
// arrive with `target` empty (ruff drops the block body). Codex
// P2 on #52: routing those through `Callback::method` sets
// `target_method = Some("")`, which downstream emitters then
// turn into an `ogar:targetMethod` triple with an empty object.
// The correct OGAR shape is a block-form Callback with
// `target_method = None` (body source `None` too, since ruff
// doesn't extract block bodies for callbacks today). Construct
// explicitly so neither side carries an empty placeholder.
if cb.target.is_empty() {
// `Callback` is `#[non_exhaustive]`, so struct-literal
// construction isn't permitted. Build via `Default` +
// mutation so target_method / body_source both stay `None`.
let mut out = Callback::default();
out.event = cb.phase.clone();
return out;
}
Callback::method(&cb.phase, &cb.target)
}
// ───────────────────────────── validations ──────────────────────────────
fn lift_validation(v: &RuffValidation) -> Option<Validation> {
// `normalizes :attr, with: ...` is semantically distinct from
// `validates :attr, ...` — it's a write-time transformation, not
// a constraint. OGAR `Validation` is the constraint shelf;
// normalizes belongs elsewhere (no current OGAR slot). Skip.
if matches!(v.kind, ValidationKind::Normalizes) {
return None;
}
// Flatten kind + target + option keys into the `rule_source`
// verbatim chunk — consumers can re-parse per ORM. The ruff side
// already carries structured kinds via the `validation_kind` and
// `validation_param` predicate triples for downstream schema
// consumers; OGAR's Class shape stays opaque-rule-source.
let mut rule = String::new();
rule.push_str(match v.kind {
ValidationKind::Validate => "validate",
ValidationKind::Validates => "validates",
ValidationKind::ValidatesAssociated => "validates_associated",
ValidationKind::ValidatesEach => "validates_each",
ValidationKind::Normalizes => unreachable!("filtered above"),
});
if !v.target.is_empty() {
rule.push(' ');
rule.push_str(&v.target);
}
for (k, val) in &v.options {
rule.push_str(", ");
rule.push_str(k);
rule.push_str(": ");
rule.push_str(val);
}
Some(Validation::new(&v.target, rule))
}
// ───────────────────────────── STI ──────────────────────────────────────
fn sti_parent(sti: &StiInfo) -> Option<String> {
sti.inherits_from.clone()
}
/// Metabolize Rails STI facts into the agnostic [`Inheritance`] slot.
///
/// Priority: a declared parent makes it an [`Inheritance::Concrete`] child
/// regardless of other flags; `abstract_class` makes it
/// [`Inheritance::Abstract`]; an `inheritance_column` with no parent makes
/// it the [`Inheritance::RootedAt`] root of a hierarchy; otherwise
/// [`Inheritance::Root`]. Mixins / concerns are NOT consulted — they are a
/// separate axis (`Class.mixins`).
fn lift_inheritance(model: &Model) -> Inheritance {
match model.sti.as_ref() {
Some(sti) if sti.inherits_from.is_some() => Inheritance::Concrete {
parent: sti.inherits_from.clone().expect("checked is_some"),
},
Some(sti) if sti.abstract_class => Inheritance::Abstract,
Some(sti) if sti.inheritance_column.is_some() => Inheritance::RootedAt {
root: model.name.clone(),
},
_ => Inheritance::Root,
}
}
// ───────────────────────────── helpers ──────────────────────────────────
/// Strip ruby-source markers (quote pairs, leading symbol colon) from
/// an option value. `walk::format_hash_inline` in `ruff_ruby_spo`
/// renders these verbatim; consumers want the bare token.
fn strip_ruby_literal_markers(s: &str) -> &str {
for q in ['"', '\''] {
if let Some(inner) = s.strip_prefix(q).and_then(|t| t.strip_suffix(q)) {
return inner;
}
}
s.strip_prefix(':').unwrap_or(s)
}
fn parse_bool(s: &str) -> Option<bool> {
match s.trim() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ruff_spo_triplet::{
ActsAs, AssocDecl, AttrDecl, Callback as RuffCallback, ConcernRef, Field, Function,
ScopeDecl, Validation as RuffValidation,
};
fn mk_model() -> Model {
let mut m = Model::new("WorkPackage");
m.associations.push(AssocDecl {
kind: AssocKind::BelongsTo,
name: "project".to_string(),
options: vec![
("class_name".to_string(), "\"Project\"".to_string()),
("optional".to_string(), "true".to_string()),
("dependent".to_string(), ":destroy".to_string()),
],
});
m.associations.push(AssocDecl {
kind: AssocKind::HasMany,
name: "time_entries".to_string(),
options: vec![("dependent".to_string(), ":delete_all".to_string())],
});
m.associations.push(AssocDecl {
kind: AssocKind::AcceptsNestedAttributesFor,
name: "comments".to_string(),
options: vec![],
});
m.validations.push(RuffValidation {
kind: ValidationKind::Validates,
target: "subject".to_string(),
options: vec![("presence".to_string(), "true".to_string())],
});
m.validations.push(RuffValidation {
kind: ValidationKind::Normalizes,
target: "email".to_string(),
options: vec![],
});
m.callbacks.push(RuffCallback {
phase: "before_save".to_string(),
target: "set_status".to_string(),
options: Vec::new(),
});
m.concerns.push(ConcernRef {
kind: ConcernKind::Include,
module: "Acts::Customizable".to_string(),
body_ref: None,
});
m.concerns.push(ConcernRef {
kind: ConcernKind::ClassMethodsBlock,
module: String::new(),
body_ref: None,
});
m.attributes.push(AttrDecl {
kind: AttrKind::Attribute,
name: "age".to_string(),
options: vec![("type".to_string(), "integer".to_string())],
});
m.attributes.push(AttrDecl {
kind: AttrKind::AliasAttribute,
name: "new=orig".to_string(),
options: vec![],
});
m.scopes.push(ScopeDecl {
kind: ScopeKind::Scope,
name: "active".to_string(),
body_ref: "where(active: true)".to_string(),
});
m.scopes.push(ScopeDecl {
kind: ScopeKind::DefaultScope,
name: String::new(),
body_ref: "order(:id)".to_string(),
});
m.acts_as.push(ActsAs {
variant: "list".to_string(),
options: vec![],
});
m.sti = Some(StiInfo {
inherits_from: Some("Issue".to_string()),
abstract_class: false,
inheritance_column: Some("type".to_string()),
});
m
}
#[test]
fn lift_model_carries_name_and_parent() {
let class = lift_model(&mk_model());
assert_eq!(class.name, "WorkPackage");
assert_eq!(class.parent.as_deref(), Some("Issue"));
assert!(matches!(class.language, Language::Ruby));
}
#[test]
fn lift_model_python_stamps_python_language() {
// The Python/Odoo producer path: same projection, Python discriminant.
let class = lift_model_python(&mk_model());
assert!(matches!(class.language, Language::Python));
assert_eq!(class.name, "WorkPackage");
assert_eq!(class.parent.as_deref(), Some("Issue"));
}
#[test]
fn lift_model_graph_python_stamps_python_and_keeps_erp_domain() {
// An Odoo ModelGraph (namespace "odoo") lifts as Python and routes to
// the `erp` source domain / `odoo` curator via classify_domain.
let mut graph = ModelGraph::new("odoo");
graph.models.push(Model::new("account_move"));
let classes = lift_model_graph_python(&graph);