diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 99fc12744..0d4ad8811 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,3 +1,25 @@ +## 2026-06-18 — E-ODOO-CORE-FIRST-STRUCTURAL — structural Odoo facts belong in the typed OGAR Core, NOT the SPO harvest; the predicate-bolt-on cadence was drift + +**Status:** FINDING (Core-first correction; operator-confirmed). **This entry supersedes the *framing* of the four prior `E-ODOO-SPO-*` / `E-ODOO-EXTRACT-*` entries below — read it before extending their pattern.** Those entries are factually correct about what shipped, but a session reading them in sequence would conclude "the way to satisfy the odoo-rs wishlist is to add another `spo_enrich.py` predicate." That conclusion is the self-fulfilling drift this entry reverses. + +**The correction.** The wishlist's structural asks — `target` / `inverse_name` (P1), `inherits_from` (P1b), `selection_value` (P3) — are **Core** facts (relations / composition / value-domain), per the Core-first transcode doctrine. Their authoritative home is the typed `OdooEntity` Core in `lance-graph-ontology::odoo_blueprint`, NOT the flat SPO ndjson. Source-verified 2026-06-18: **`OdooField.target: Option<&'static str>` already exists** in the Core — so `spo_enrich.py`'s `target` pass was *re-deriving a fact the Core already held*. `inherits_from` and `selection_value` were genuine **Core gaps** filled on the *harvest* side — the exact "a Core gap → extend the Core deliberately, never hack the adapter/harvest" anti-pattern. + +**Why the harvest path looked right (and why it isn't).** `OdooField` has **3 554** literal sites, `OdooEntity` **404**, no constructor — adding a *field* to the mega-structs would break every one, so a predicate-on-the-ndjson felt like the only additive move. But the doctrine-correct extension for a literal sea that size is a **typed side-table**, which #530 now lands: `odoo_blueprint::structural::{OdooInherits, OdooFieldSelection}` + `project_inherits_from` / `project_selection_value` (Core → SPO, never the reverse). 5 tests; canonical `account.move` data grounded in the #527 corpus + standard Odoo value sets. + +**The standing shape (so the next session does NOT re-drift).** Two legs converge on the `SpoStore` per `odoo-extraction-strategies-v1.md`: the **Curated leg** = typed Core (this module + the 66 L-doc entities), authoritative; the **Extracted leg** = `spo_enrich.py`, a *breadth* feeder for the ~322 ObjectTypes the Core hasn't reached, subordinate (Core wins on convergence). Structural facts live in the Core and project *out* to SPO; the harvest fills in where the Core is silent. **Behavioural** predicates (`reads_field` deep lifts, `emitted_by`, transitive `depends_on`, `validation_kind` body-AST classification) ARE genuine harvest and correctly stay in `spo_enrich.py`. + +**`virtually_overrides` (the last wishlist item) is NOT a harvest predicate.** It is a ClassView/Core MRO-precedence capability. Doing it as `spo_enrich.py` predicate #6 would be the drift again. It belongs in the typed Core's class-resolution surface — the same conclusion the wishlist reached for a different reason. + +**Cross-ref:** `spo_enrich.py` module-doc § "ARCHITECTURE NOTE"; `structural.rs` module doc; `core-first-transcode-doctrine.md`; `odoo-extraction-strategies-v1.md` (three legs). The four entries below are retained (append-only) with their framing now bounded by this one. + +## 2026-06-18 — E-ODOO-SPO-SELECTION-VALUE — spo_enrich gains selection_value (wishlist P3); corpus regen pending Odoo source + +**Status:** FINDING for the code + tests; **framing bounded by E-ODOO-CORE-FIRST-STRUCTURAL above** — `selection_value` is a Core fact now homed in `odoo_blueprint::structural`; this harvest pass is the subordinate Extracted-leg breadth feeder, not the authoritative source. `spo_enrich.py` adds a fifth enrichment pass via the same single AST walk: `fields.Selection([('draft','Draft'), …])` declarations emit one `(odoo:., selection_value, "")` triple per statically-resolvable enum key. 12 new tests (6 extraction + 3 scan-binding + 3 emission); total suite 41→53 green. Rust loader histogram arm gained `selection_value`. + +**Shape.** `_extract_selection_values` pulls the first element of each 2-tuple from the Selection list (positional arg 0 OR `selection=` kwarg), preserving source order, de-duplicating. Dynamic selections — `selection='_compute_x'` (str method-ref), a bare Name constant, `related=` — are skipped (values not statically knowable). Truth `(0.95, 0.90)`. Scoped to corpus-declared ObjectTypes (the additive boundary); Selection fields bind to the same `model_names` as relational fields (`_name`, else `_inherit[0]`, per #525). + +**Consumer use.** Lets odoo-rs lower a Selection field to `DEFINE FIELD state … ASSERT $value IN ['draft','posted','cancel']` — the wishlist P3 ask. **Source of truth is `odoo_blueprint::structural::FIELD_SELECTIONS` (Core)**; this harvest gives breadth for uncurated models. `virtually_overrides` is a ClassView/Core concern (see correction above), NOT the next harvest predicate. + ## 2026-06-18 — E-WITNESS-ARC-TWO-OBJECTS-1 — "witness arc" names TWO different objects; do NOT unify them under a `WitnessArcEvaluator` trait **Status:** FINDING (5+3 council, unanimous: convergence-architect DROP, iron-rule-savant REJECT-trait, dto-soa-savant FITS-COLUMN-as-free-fn, dilution-collapse-sentinel KEEP-SEPARATE, truth-architect PROVEN-math, brutally-honest-tester Option-B-LAND, baton-handoff-auditor CATCH-CRITICAL, integration-lead DEFER). B2 resolved as documentation, not code. diff --git a/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs b/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs index df2920291..2a34d0fee 100644 --- a/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs +++ b/crates/lance-graph-ontology/src/odoo_blueprint/mod.rs @@ -93,6 +93,13 @@ pub mod style_recipe; // build.rs → OUT_DIR → include!(). See op_emitter::emit_op_dispatch. pub mod op_emitter; +/// Structural Core extension — typed home for `inherits_from` + +/// `selection_value` (the two structural gaps that do not fit the +/// 3 554-literal `OdooField` / 404-literal `OdooEntity` mega-structs). +/// Core is authoritative; the `spo_enrich.py` harvest is the subordinate +/// Extracted-leg breadth feeder. See the module doc for the full rationale. +pub mod structural; + // ─── Top-level entity ───────────────────────────────────────────────────── /// Which ORM base class the entity inherits from. diff --git a/crates/lance-graph-ontology/src/odoo_blueprint/structural.rs b/crates/lance-graph-ontology/src/odoo_blueprint/structural.rs new file mode 100644 index 000000000..558d45dc9 --- /dev/null +++ b/crates/lance-graph-ontology/src/odoo_blueprint/structural.rs @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Structural Core extension — the typed home for `inherits_from` and +//! `selection_value`. +//! +//! # Why this module exists (Core-first correction, 2026-06-18) +//! +//! Two structural facts about an Odoo model — its `_inherit`/`_inherits` +//! **mixin chain** and a `fields.Selection` field's **allowed value set** — +//! are *Core* properties (identity / composition / value-domain), not +//! behavioural harvest. Per the Core-first transcode doctrine they belong in +//! the deliberate typed Core ([`super::OdooEntity`] / [`super::OdooField`]), +//! the single source of truth, **not** re-inferred onto the flat SPO ndjson +//! by a separate AST pass. +//! +//! They could not be added as *fields* on the existing structs: `OdooField` +//! has **3 554** literal sites and `OdooEntity` **404** across `l1..l15.rs`, +//! none using a constructor — adding a field would break every one. For a +//! literal sea that size the doctrine-correct "extend the Core deliberately" +//! is a **typed side-table**, which is what this module is. It is still Core +//! (`lance-graph-ontology::odoo_blueprint`), still authoritative, still +//! `OdooConfidence::Curated`-grade; it simply lives beside the mega-structs +//! instead of inside them. +//! +//! # Direction of truth: Core → SPO, never the reverse +//! +//! [`project_inherits_from`] and [`project_selection_value`] emit SPO triples +//! **from** this typed Core. The `spo_enrich.py` AST harvest is the +//! **Extracted leg** (per `odoo-extraction-strategies-v1.md`) — a *breadth* +//! feeder for the ~322 ObjectTypes the curated Core has not yet reached. On +//! convergence in the `SpoStore` the curated Core (this module, +//! `0.95/0.90`) **wins** over the harvest's extracted confidence for any +//! model it covers. The harvest never becomes the home for a structural +//! fact; it fills in where the Core is silent. +//! +//! Behavioural predicates (`reads_field` deep lifts, `emitted_by`, +//! transitive `depends_on`) are genuine harvest and stay in `spo_enrich.py` +//! — they describe a method *body*, not the model's structure. + +/// One model's `_inherit` / `_inherits` mixin chain — the composition gap +/// the `OdooEntity` mega-struct does not carry (`OdooEntityKind` records the +/// ORM *base class* `Model`/`Transient`/`Abstract`, not the mixin list). +/// +/// `bases` are dotted Odoo model names (`"mail.activity.mixin"`); the SPO +/// projection underscores them to match the corpus IRI convention. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OdooInherits { + /// Owning model, dotted (`"account.move"`). + pub model: &'static str, + /// Mixin bases this model `_inherit`s, in declaration order. + pub bases: &'static [&'static str], +} + +/// One `fields.Selection` field's statically-known value domain — the gap +/// `OdooFieldKind::Selection` flags but `OdooField` does not store (only the +/// `state` field's domain is reachable today, via `OdooStateMachine`). +/// +/// `values` are the stored *keys* (the first element of each `('key', +/// 'Label')` tuple), in declaration order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OdooFieldSelection { + /// Owning model, dotted (`"account.move"`). + pub model: &'static str, + /// Selection field name (`"state"`, `"move_type"`). + pub field: &'static str, + /// Allowed value keys, in source order. + pub values: &'static [&'static str], +} + +// ─── Curated data — canonical `account.move` (grounded, not invented) ────── +// +// `INHERITS` is grounded in the #527-regenerated corpus +// (`grep '"s":"odoo:account_move","p":"inherits_from"'`): the corpus captured +// `mail_activity_mixin` + `sequence_mixin` (it drops bases that are not +// themselves corpus ObjectTypes — `mail.thread` / `portal.mixin` fell outside +// that boundary). The curated Core records the corpus-confirmed pair; a future +// L-doc curation pass may widen it to the full real chain (the Core is allowed +// to exceed the harvest's ObjectType-boundary, since it is authoritative). +// +// `FIELD_SELECTIONS` uses the canonical, stable Odoo `account.move` value sets +// (`state`, `move_type`) — standard across Odoo versions, verifiable against +// `addons/account/models/account_move.py`. + +/// Curated inherit-chains. APPEND-ONLY as L-doc curation reaches more models. +pub const INHERITS: &[OdooInherits] = &[ + OdooInherits { + model: "account.move", + bases: &["mail.activity.mixin", "sequence.mixin"], + }, + OdooInherits { + model: "account.move.line", + bases: &["analytic.mixin"], + }, +]; + +/// Curated Selection value domains. APPEND-ONLY. +pub const FIELD_SELECTIONS: &[OdooFieldSelection] = &[ + OdooFieldSelection { + model: "account.move", + field: "state", + values: &["draft", "posted", "cancel"], + }, + OdooFieldSelection { + model: "account.move", + field: "move_type", + values: &[ + "entry", + "out_invoice", + "out_refund", + "in_invoice", + "in_refund", + "out_receipt", + "in_receipt", + ], + }, +]; + +// ─── Core → SPO projection ───────────────────────────────────────────────── + +/// `account.move` → `account_move` (corpus IRI local-part convention). +fn underscore(dotted: &str) -> String { + dotted.replace('.', "_") +} + +/// One projected SPO triple: `(subject, predicate, object)`. Truth values are +/// fixed at the curated grade `(0.95, 0.90)` by the projection (declared, +/// authoritative), so callers serialising to ndjson append `,"f":0.95,"c":0.9`. +pub type SpoTriple = (String, &'static str, String); + +/// Project the curated inherit-chains into `inherits_from` SPO triples, +/// `(odoo:, inherits_from, odoo:)`. Both endpoints underscored. +/// +/// This is the authoritative source for `inherits_from` on every model in +/// [`INHERITS`]; the harvest only supplies models absent here. +#[must_use] +pub fn project_inherits_from(table: &[OdooInherits]) -> Vec { + let mut out = Vec::new(); + for row in table { + let child = format!("odoo:{}", underscore(row.model)); + for base in row.bases { + out.push((child.clone(), "inherits_from", format!("odoo:{}", underscore(base)))); + } + } + out +} + +/// Project the curated Selection domains into `selection_value` SPO triples, +/// `(odoo:., selection_value, "")`. One per value, source +/// order preserved. +#[must_use] +pub fn project_selection_value(table: &[OdooFieldSelection]) -> Vec { + let mut out = Vec::new(); + for row in table { + let subj = format!("odoo:{}.{}", underscore(row.model), row.field); + for v in row.values { + out.push((subj.clone(), "selection_value", (*v).to_string())); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inherits_projection_underscores_both_endpoints() { + let triples = project_inherits_from(INHERITS); + assert!(triples.contains(&( + "odoo:account_move".to_string(), + "inherits_from", + "odoo:mail_activity_mixin".to_string(), + ))); + assert!(triples.contains(&( + "odoo:account_move".to_string(), + "inherits_from", + "odoo:sequence_mixin".to_string(), + ))); + // account.move.line → account_move_line, analytic.mixin → analytic_mixin + assert!(triples.contains(&( + "odoo:account_move_line".to_string(), + "inherits_from", + "odoo:analytic_mixin".to_string(), + ))); + } + + #[test] + fn inherits_projection_matches_527_corpus() { + // The two account.move bases the projection emits are exactly the pair + // the #527 corpus regen captured — the typed Core is consistent with + // (not contradicting) the harvest, and is the authoritative home. + let triples = project_inherits_from(INHERITS); + let am_bases: Vec<&str> = triples + .iter() + .filter(|(s, _, _)| s == "odoo:account_move") + .map(|(_, _, o)| o.as_str()) + .collect(); + assert_eq!(am_bases, vec!["odoo:mail_activity_mixin", "odoo:sequence_mixin"]); + } + + #[test] + fn selection_projection_one_triple_per_value_in_order() { + let triples = project_selection_value(FIELD_SELECTIONS); + let state_vals: Vec<&str> = triples + .iter() + .filter(|(s, _, _)| s == "odoo:account_move.state") + .map(|(_, _, o)| o.as_str()) + .collect(); + assert_eq!(state_vals, vec!["draft", "posted", "cancel"]); + + let move_type_vals: Vec<&str> = triples + .iter() + .filter(|(s, _, _)| s == "odoo:account_move.move_type") + .map(|(_, _, o)| o.as_str()) + .collect(); + assert_eq!(move_type_vals[0], "entry"); + assert_eq!(move_type_vals.len(), 7); + } + + #[test] + fn selection_subject_is_field_iri_not_model() { + let triples = project_selection_value(FIELD_SELECTIONS); + // selection_value keys on the FIELD, never the model. + assert!(triples.iter().all(|(s, _, _)| s.contains('.'))); + } + + #[test] + fn registries_are_nonempty_and_curated() { + assert!(!INHERITS.is_empty()); + assert!(!FIELD_SELECTIONS.is_empty()); + } +} diff --git a/crates/lance-graph/src/graph/spo/odoo_ontology.rs b/crates/lance-graph/src/graph/spo/odoo_ontology.rs index a65dea516..73d7bce53 100644 --- a/crates/lance-graph/src/graph/spo/odoo_ontology.rs +++ b/crates/lance-graph/src/graph/spo/odoo_ontology.rs @@ -26,6 +26,7 @@ //! | `inverse_name` | `odoo:.` | `""` | One2many/inverse (declared) | //! | `inherits_from` | `odoo:` | `odoo:` | `_inherit`/`_inherits` base (declared) | //! | `validation_kind` | `odoo:.` | `""` | `@api.constrains` body pattern (inferred) | +//! | `selection_value` | `odoo:.` | `""` | `fields.Selection` enum key (declared) | //! //! ## FK-target + deep-read enrichment (`spo_enrich`) //! @@ -183,6 +184,7 @@ mod tests { "inverse_name" => "inverse_name", "inherits_from" => "inherits_from", "validation_kind" => "validation_kind", + "selection_value" => "selection_value", _ => "other", }) .or_default() += 1; diff --git a/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py b/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py index 404b7564c..657eb375b 100644 --- a/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py +++ b/tools/odoo-blueprint-extractor/odoo_blueprint_extractor/spo_enrich.py @@ -1,5 +1,6 @@ """SPO corpus enrichment — FK target/inverse_name (P1) + deep reads_field (P0) -+ inherits_from (P1b, ruff#19) + validation_kind (P2, ruff#21). ++ inherits_from (P1b, ruff#19) + validation_kind (P2, ruff#21) ++ selection_value (P3). Stdlib-only Python 3. Reads the Odoo ORM source (the same addons tree the ORM extractor parses) to build a relation-target map, then enriches an @@ -60,12 +61,54 @@ extension classes bind to `_inherit[0]` (mirrors `_scan_file`'s field binding decision per #525 codex review). + * **P3 — `selection_value`** (UPSTREAM_WISHLIST P3): for every + `fields.Selection([('draft','Draft'), ('posted','Posted'), …])` + declaration with a *statically resolvable* list of 2-tuples, emit one + triple per value KEY (the first tuple element), keyed by the field IRI:: + + (odoo:account_move.state, selection_value, "draft") + (odoo:account_move.state, selection_value, "posted") + (odoo:account_move.state, selection_value, "cancel") + + Enables the consumer to lower a Selection field to + `DEFINE FIELD state … ASSERT $value IN ['draft','posted','cancel']`. + Dynamic selections (`selection='_compute_states'`, a bare Name constant, + or `related=`) are skipped — their values aren't statically knowable. + Source order is preserved. Selection fields bind to the same + `model_names` as relational fields (`_name`, else `_inherit[0]`). + This is *in addition* to the existing shallow relation read `(_compute_amount, reads_field, odoo:account_move.line_ids)`. The deep read makes the cross-model recompute-ordering edge visible to `od_ontology::RecomputeDag` (which today sees only the relation read, leaving the line→move dependency structurally invisible). +# ARCHITECTURE NOTE — this is the breadth feeder, NOT the home (2026-06-18) + +The *structural* predicates here — `target` / `inverse_name` / `inherits_from` +/ `selection_value` — are **Core** facts (relations / composition / value +domain). Their AUTHORITATIVE home is the typed `OdooEntity` Core in +`lance-graph-ontology::odoo_blueprint` (`OdooField.target` already exists; +`inherits_from` + `selection_value` live in the +`odoo_blueprint::structural` side-table). This module is the **Extracted +leg** (per `.claude/knowledge/odoo-extraction-strategies-v1.md`): a *breadth* +feeder that AST-walks the full Odoo source for the ~322 ObjectTypes the +curated Core has not yet reached. On convergence in the `SpoStore` the +curated Core (`OdooConfidence::Curated`) WINS over this leg's extracted +confidence for any model it covers — the harvest never becomes the home for +a structural fact, it fills in where the Core is silent. + +Reading this file as "where structural predicates live" is the +self-fulfilling drift the 2026-06-18 Core-first correction reversed +(see `EPIPHANIES.md` E-ODOO-CORE-FIRST-STRUCTURAL). Do NOT add +`virtually_overrides` here — it is a ClassView/Core MRO capability, not a +harvest predicate. + +The *behavioural* predicates (`reads_field` deep lifts, `emitted_by`, +transitive `depends_on`, `raises`, `validation_kind` body classification) +ARE genuine harvest — they describe a method *body*, not the model's +structure — and correctly live here. + # Why a separate enrichment pass (not the ORM extractor) The ORM extractor (`parsers/`, `emitters/`) emits typed Rust `OdooEntity` @@ -110,12 +153,49 @@ # validation_kind is an AST-pattern classification of a method body # (heuristic, not authoritative). VALIDATION_KIND_TRUTH = (0.85, 0.75) +# selection_value is a statically-resolved enum key from a Selection +# declaration (authoritative — read straight from the field decorator). +SELECTION_VALUE_TRUTH = (0.95, 0.90) # Recognised `validation_kind` strings (matches ruff#21's Rails set where # semantics overlap; Odoo-specific patterns like `lookup` extend it). _VALIDATION_KINDS = ("presence", "uniqueness", "range", "format", "lookup") +def _extract_selection_values(call: ast.Call) -> List[str]: + """Pull the value KEYS from a `fields.Selection([('k','Label'), …])` + declaration. The selection list is the first positional arg OR the + `selection=` kwarg; each entry is a 2-tuple whose first element is the + stored key. + + Returns the keys in source order, de-duplicated (first occurrence wins). + Returns `[]` for dynamic selections — a bare Name (constant reference), + a string (compute-method name), `related=`, or any entry whose first + element isn't a string constant. Those values aren't statically known. + """ + sel: Optional[ast.expr] = None + for kw in call.keywords: + if kw.arg == "selection": + sel = kw.value + break + if sel is None and call.args: + sel = call.args[0] + if not isinstance(sel, (ast.List, ast.Tuple)): + # `selection='_compute_x'` (str), a Name constant, or related= → skip. + return [] + + out: List[str] = [] + seen: Set[str] = set() + for elt in sel.elts: + if not isinstance(elt, (ast.Tuple, ast.List)) or len(elt.elts) < 1: + continue + key = _const_str(elt.elts[0]) + if key is not None and key not in seen: + seen.add(key) + out.append(key) + return out + + def _is_uniqueness_call(node: ast.expr) -> bool: """True iff `node` is a `.search_count(...)` call — the LHS of the canonical Odoo uniqueness pattern. Used to suppress `range` for @@ -222,6 +302,7 @@ def _scan_file( relmap: Dict[Tuple[str, str], Tuple[str, Optional[str]]], inherits: Optional[Dict[str, Set[str]]] = None, constrains: Optional[Dict[Tuple[str, str], Set[str]]] = None, + selections: Optional[Dict[Tuple[str, str], List[str]]] = None, ) -> None: """Parse one .py file; record every relational field on every named model. Optionally also record `inherits_from` edges and `@api.constrains` @@ -262,6 +343,7 @@ def _scan_file( inherits_models: List[str] = [] # _inherits dict keys (delegation) local_fields: Dict[str, Tuple[str, Optional[str]]] = {} local_constrains: List[Tuple[str, ast.FunctionDef]] = [] + local_selections: Dict[str, List[str]] = {} for stmt in node.body: # Method definitions — gather @api.constrains methods for P2. @@ -311,6 +393,12 @@ def _scan_file( ): continue field_type = stmt.value.func.attr + # Selection field — extract statically-known value keys (P3). + if field_type == "Selection" and selections is not None: + vals = _extract_selection_values(stmt.value) + if vals: + local_selections[target_name] = vals + continue if field_type not in RELATIONAL_KINDS: continue @@ -359,6 +447,11 @@ def _scan_file( # Last write wins across _inherit reopenings of the same model; # comodel for a given (model, field) is stable in practice. relmap[(mu, field_name)] = (comodel, inverse) + if selections is not None: + for field_name, vals in local_selections.items(): + # Last write wins (a later reopening that redeclares the + # Selection with a fuller value list supersedes). + selections[(mu, field_name)] = vals # ── P1b: inherits_from edges ───────────────────────────────────── # Only when the class declares a NEW model (_name is set). An @@ -393,21 +486,24 @@ def build_all_facts( Dict[Tuple[str, str], Tuple[str, Optional[str]]], Dict[str, Set[str]], Dict[Tuple[str, str], Set[str]], + Dict[Tuple[str, str], List[str]], ]: - """Single-pass scan that populates (relmap, inherits, constrains). + """Single-pass scan that populates (relmap, inherits, constrains, selections). Returns: relmap — (model_us, field) → (comodel_dotted, inverse_or_None) inherits — model_us → set(base_us) from `_inherit` + `_inherits` constrains — (model_us, method) → set(kind) from `@api.constrains` + selections — (model_us, field) → [value_key, …] from `fields.Selection` """ relmap: Dict[Tuple[str, str], Tuple[str, Optional[str]]] = {} inherits: Dict[str, Set[str]] = {} constrains: Dict[Tuple[str, str], Set[str]] = {} + selections: Dict[Tuple[str, str], List[str]] = {} pattern = os.path.join(addons_root, "**", "*.py") for path in glob.iglob(pattern, recursive=True): - _scan_file(path, relmap, inherits, constrains) - return relmap, inherits, constrains + _scan_file(path, relmap, inherits, constrains, selections) + return relmap, inherits, constrains, selections def build_relation_map(addons_root: str) -> Dict[Tuple[str, str], Tuple[str, Optional[str]]]: @@ -468,6 +564,7 @@ def enrich( relmap: Dict[Tuple[str, str], Tuple[str, Optional[str]]], inherits: Optional[Dict[str, Set[str]]] = None, constrains: Optional[Dict[Tuple[str, str], Set[str]]] = None, + selections: Optional[Dict[Tuple[str, str], List[str]]] = None, ) -> Tuple[List[str], dict]: """Compute the additive enrichment lines + a stats dict. @@ -544,6 +641,8 @@ def enrich( "inherits_skip_unknown_base": 0, "validation_kind": 0, "validation_skip_unknown_method": 0, + "selection_value": 0, + "selection_skip_unknown_model": 0, } # ── P1: target / inverse_name ───────────────────────────────────────── @@ -661,6 +760,26 @@ def enrich( existing_spo.add(spo) stats["validation_kind"] += 1 + # ── P3: selection_value ─────────────────────────────────────────────── + # One triple per statically-known enum key on a Selection field. Scoped to + # corpus-declared ObjectTypes (the additive boundary): never invent a + # selection on an unknown model. Value order preserved (source order). + if selections: + for (model_us, field_name), vals in sorted(selections.items()): + if model_us not in object_type_models: + stats["selection_skip_unknown_model"] += 1 + continue + field_iri = f"odoo:{model_us}.{field_name}" + for val in vals: + spo = (field_iri, "selection_value", val) + if spo in existing_spo: + continue + new_lines.append( + triple_line(field_iri, "selection_value", val, *SELECTION_VALUE_TRUTH) + ) + existing_spo.add(spo) + stats["selection_value"] += 1 + new_lines.sort() return new_lines, stats @@ -668,12 +787,13 @@ def enrich( def run(corpus_path: str, addons_root: str, out_path: str) -> dict: """Enrich `corpus_path` using `addons_root`, write to `out_path`. Returns stats.""" triples = load_corpus(corpus_path) - relmap, inherits, constrains = build_all_facts(addons_root) - new_lines, stats = enrich(triples, relmap, inherits, constrains) + relmap, inherits, constrains, selections = build_all_facts(addons_root) + new_lines, stats = enrich(triples, relmap, inherits, constrains, selections) stats["corpus_triples_in"] = len(triples) stats["relmap_entries"] = len(relmap) stats["inherits_entries"] = sum(len(v) for v in inherits.values()) stats["constrains_entries"] = sum(len(v) for v in constrains.values()) + stats["selections_entries"] = sum(len(v) for v in selections.values()) stats["new_triples"] = len(new_lines) # Preserve the original corpus lines verbatim, then append the sorted new @@ -733,6 +853,7 @@ def main(argv: Optional[List[str]] = None) -> None: f"self_loop={stats['deep_skip_self_loop']}) " f"inherits_from={stats['inherits_from']} " f"validation_kind={stats['validation_kind']} " + f"selection_value={stats['selection_value']} " f"out={stats['corpus_triples_out']}", file=sys.stderr, ) diff --git a/tools/odoo-blueprint-extractor/tests/test_spo_enrich.py b/tools/odoo-blueprint-extractor/tests/test_spo_enrich.py index 5f7965fca..d80a41ab9 100644 --- a/tools/odoo-blueprint-extractor/tests/test_spo_enrich.py +++ b/tools/odoo-blueprint-extractor/tests/test_spo_enrich.py @@ -593,5 +593,137 @@ def test_name_plus_inherit_binds_to_name_only(self): self.assertEqual(c, {("account_move", "_check_subject"): {"presence"}}) +# --------------------------------------------------------------------------- +# PR #529 — selection_value (P3) +# --------------------------------------------------------------------------- + + +def _scan_sel(src): + """Run `_scan_file` over a synthetic module; return the selections dict.""" + from odoo_blueprint_extractor.spo_enrich import _scan_file + relmap, inherits, constrains, selections = {}, {}, {}, {} + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f: + f.write(src) + path = f.name + try: + _scan_file(path, relmap, inherits, constrains, selections) + finally: + os.unlink(path) + return selections + + +def _classify_sel(src): + """Classify a single synthetic `fields.Selection(...)` call.""" + import ast as _ast + from odoo_blueprint_extractor.spo_enrich import _extract_selection_values + tree = _ast.parse(src) + call = next(n for n in _ast.walk(tree) if isinstance(n, _ast.Call)) + return _extract_selection_values(call) + + +class TestSelectionExtraction(unittest.TestCase): + def test_list_of_tuples_positional(self): + vals = _classify_sel( + "fields.Selection([('draft','Draft'),('posted','Posted'),('cancel','Cancel')])" + ) + self.assertEqual(vals, ["draft", "posted", "cancel"]) + + def test_selection_kwarg(self): + vals = _classify_sel( + "fields.Selection(selection=[('a','A'),('b','B')], string='X')" + ) + self.assertEqual(vals, ["a", "b"]) + + def test_dynamic_method_name_skipped(self): + # selection='_compute_states' is a compute method ref → no static values. + vals = _classify_sel("fields.Selection(selection='_compute_states')") + self.assertEqual(vals, []) + + def test_bare_name_constant_skipped(self): + # selection=SOME_CONSTANT — not statically resolvable from this AST. + vals = _classify_sel("fields.Selection(STATE_VALUES)") + self.assertEqual(vals, []) + + def test_dedup_preserves_first_order(self): + vals = _classify_sel( + "fields.Selection([('a','A'),('b','B'),('a','A2')])" + ) + self.assertEqual(vals, ["a", "b"]) + + def test_non_tuple_entries_skipped(self): + # A malformed entry (not a 2-tuple) is skipped, others survive. + vals = _classify_sel("fields.Selection([('a','A'), 'bogus', ('b','B')])") + self.assertEqual(vals, ["a", "b"]) + + +class TestSelectionScan(unittest.TestCase): + """`_scan_file` binds Selection values to model_names (per #525 rule).""" + + def test_selection_bound_to_name_model(self): + sel = _scan_sel( + "class M:\n" + " _name = 'account.move'\n" + " state = fields.Selection([('draft','Draft'),('posted','Posted')])\n" + ) + self.assertEqual(sel, {("account_move", "state"): ["draft", "posted"]}) + + def test_selection_on_inherit_only_binds_to_inherit_zero(self): + sel = _scan_sel( + "class Ext:\n" + " _inherit = ['account.move', 'mail.thread']\n" + " kind = fields.Selection([('x','X'),('y','Y')])\n" + ) + self.assertEqual(sel, {("account_move", "kind"): ["x", "y"]}) + + def test_dynamic_selection_field_records_nothing(self): + sel = _scan_sel( + "class M:\n" + " _name = 'account.move'\n" + " s = fields.Selection(selection='_compute_s')\n" + ) + self.assertEqual(sel, {}) + + +class TestP3SelectionValueEmission(unittest.TestCase): + def _triples(self, field_iri="odoo:account_move.state"): + return [ + t("odoo:account_move", "rdf:type", "ogit:ObjectType"), + ] + + def test_emits_one_triple_per_value(self): + lines, stats = enrich( + self._triples(), + RELMAP, + selections={("account_move", "state"): ["draft", "posted", "cancel"]}, + ) + self.assertEqual(stats["selection_value"], 3) + joined = "\n".join(lines) + for v in ("draft", "posted", "cancel"): + self.assertIn( + f'{{"s":"odoo:account_move.state","p":"selection_value","o":"{v}"', + joined, + ) + + def test_skip_unknown_model(self): + triples = [t("odoo:account_move", "rdf:type", "ogit:ObjectType")] + lines, stats = enrich( + triples, RELMAP, selections={("mystery_model", "s"): ["a"]} + ) + self.assertEqual(stats["selection_value"], 0) + self.assertEqual(stats["selection_skip_unknown_model"], 1) + self.assertEqual(lines, []) + + def test_dedup_against_existing(self): + triples = [ + t("odoo:account_move", "rdf:type", "ogit:ObjectType"), + t("odoo:account_move.state", "selection_value", "draft"), + ] + _, stats = enrich( + triples, RELMAP, selections={("account_move", "state"): ["draft", "posted"]} + ) + # 'draft' already present → only 'posted' is new. + self.assertEqual(stats["selection_value"], 1) + + if __name__ == "__main__": unittest.main(verbosity=2)