From 4102eb04ff7e6fb3e7fe1b7815f9eb00baee2226 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 10:11:09 +0000 Subject: [PATCH 1/5] harvest: stand up the ORM->AR back-projection resolver config (data) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5 step 2 of the OGAR V3 consumer-migration plan (.claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md) — the additive, zero-risk first step. Stands up .claude/harvest/ as the ONE training wheel op-nexgen owns: the ORM→AR back-projection, promoted from the buried D-AR-3.5 vendor patch to explicit resolver config (data). Files: - README.md — what the directory is (the one training wheel), the data-not-code doctrine, the measure-don't-claim oracle discipline, the rules-file schema, and the §4-vs-schema.rs discrepancy log. - orm-ar-backprojection.toml — 15 [[rule]] rows (7 direct / 6 guess / 2 weak). Rules sourced from plan §4 + the vendored D-AR-3.5 harvest (vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/schema.rs): - §4 table rows → typed_column (row1), null_false_presence (row2), foreign_key_name (row3), polymorphic_pair (row4), unique_index (row5), habtm_join_table (row6), counter_cache (row7), and the §4 row8 lft/rgt|parent_id split into nested_set + adjacency_tree. - schema.rs rules the §4 table omits → explicit_column (t.column form), implicit_pk + id_false_suppresses_pk (PK defaults), timestamps_pair, and the DIRECT DSL association extraction references_belongs_to + references_polymorphic (higher-confidence than §4's name-pattern guess). All 15 rules are validation="unmeasured" pending the AR-oracle pass; no rule ships as coverage until the 90/10 oracle diff confirms it. op-codegen-residual is untouched: its RESIDUAL_MANIFEST is output-side three-buckets data (a different concern) whose migration is a later step (§5 step 6); the README records where it lives. Doctrines restated: config is data — where data is insufficient, make ruff smarter (spec it, don't fake it here); measure, don't claim — every guess is validated against the AR oracle, no shipped coverage unmeasured. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D9qw3LYDprZi4uts2m57iq --- .claude/harvest/README.md | 168 ++++++++++++++++ .claude/harvest/orm-ar-backprojection.toml | 212 +++++++++++++++++++++ 2 files changed, 380 insertions(+) create mode 100644 .claude/harvest/README.md create mode 100644 .claude/harvest/orm-ar-backprojection.toml diff --git a/.claude/harvest/README.md b/.claude/harvest/README.md new file mode 100644 index 0000000..3ceb09c --- /dev/null +++ b/.claude/harvest/README.md @@ -0,0 +1,168 @@ +# `.claude/harvest/` — the ORM→AR back-projection resolver config + +> Stood up per §5 step 2 of +> `.claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md` +> (the additive, zero-risk first step). This directory is **data**, not +> code: it is the ONE training wheel op-nexgen owns after the OGAR V3 +> transpiler correction. Everything else in the codegen stack retires into +> the upstream pipeline (`ruff` + `OGAR` + lance V3). + +## 1. What this directory is + +op-nexgen's narrowed role is **thin consumer of the OGAR V3 transpiler + +exactly one training wheel** (migration plan §3). The training wheel is the +**ORM→AR back-projection**: mapping **ORM-shaped source** (a Rails migration +DSL / physical DB schema) *before* it is fully AR/Rails/Ruby, so we can +**back-project the DB schema into guessed ActiveRecord behaviour** and recover +the residual the AR extraction cannot see on its own. + +ruff is already smart for AR/Rails/Ruby (the class-body / method / validation +strata). The **only** gap is the *column* stratum: the physical schema in +`db/migrate/tables/*.rb`, and the AR associations/validations implied by it. +The WorkPackage oracle diff (migration plan §4; RESIDUAL-THREE-BUCKETS.md §4c) +measured that **~90 % of a hand-written Rust model struct derives from the +column stratum alone** (name + type + nullability), with the remaining ~10 % +coming from validation triples the expander already ships — i.e. the column +stratum + validation triples ≈ 100 % of the model shape. This config is the +data that turns those column facts into guessed AR declarations, closing the +90 → 100 gap the oracle diff left open. + +The crude v1 of exactly this lived, buried, in the vendored ruff patch +**D-AR-3.5** (`vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/schema.rs`). +This directory promotes it from *code buried in a vendor mirror* to +**resolver config (data)** that op-nexgen owns explicitly. + +- Input: `orm-ar-backprojection.toml` — one `[[rule]]` per ORM/DB shape → + guessed AR declaration. + +## 2. The data-not-code doctrine + +**Config is data.** The back-projection rules are declarative pattern → +declaration mappings, stored as TOML. op-nexgen does **not** detect, address, +or transpile — that intelligence lives in ruff (`soc` detect / `mint` address +/ `propose`). This config is the one place where op-nexgen supplies a small, +enumerable table of ORM-shape heuristics. + +**Where the data is insufficient, make *ruff* smarter — spec it, don't fake +it here** (migration plan §6 guardrail). Two categories of rule live at two +different homes: + +- **Pure pattern → declaration** (a column type maps to a field; a `null: + false` maps to a presence guess; an `_id` name maps to a `belongs_to` + guess) = **data** → lives here as resolver config. +- **Rules that need the *actual* AR code** to be correct (don't guess + `belongs_to` if the association is already declared; is a DB constraint + truly a validation or just a storage invariant?) = **ruff gets smarter**. + Those are specced to a ruff session, never faked in op-nexgen. A rule here + whose `notes` says "needs ruff to lift X first" is a marker that the + correct fix is upstream, not a richer heuristic in this file. + +## 3. The oracle-validation discipline — measure, don't claim + +**Every guess is validated against the AR oracle** (the 90/10 oracle-diff +discipline; migration plan §4, §4c). No coverage ships that was not measured. +The oracle is the three-way diff: DB schema (ground truth) × hand-written Rust +× measured extraction; each guessed declaration must reproduce the AR the +oracle witnesses, or it is corrected/retired. + +**Nothing in `orm-ar-backprojection.toml` is oracle-validated yet.** Every +rule carries `validation = "unmeasured"`. That is deliberate and honest: this +step (§5 step 2) stands up the config *additively*, ahead of the AR-oracle +pass. When the oracle pass runs, each rule's `validation` moves from +`"unmeasured"` to a measured verdict (e.g. `"confirmed"` / `"corrected"` / +`"retired"`), and no rule is treated as shipped coverage until it has one. + +## 4. Schema of `orm-ar-backprojection.toml` + +A `[meta]` table (schema version + doctrine one-liners + the `kind` / +`validation` legends), then an array of `[[rule]]` tables. Fields per rule: + +| field | meaning | +|---|---| +| `id` | stable machine handle (snake_case, unique, append-only) | +| `input_name` | human name of the ORM/DB shape the rule keys on | +| `input_pattern` | structural description of the input (which stratum, what shape) | +| `input_regex` | a regex matching the input line or harvested field name, where one applies (`""` when the shape is purely structural) | +| `guessed_output` | the AR declaration this rule proposes | +| `kind` | `"direct"` \| `"guess"` \| `"weak"` (see legend below) | +| `validation` | oracle-validation state; **all `"unmeasured"`** at stand-up | +| `source` | provenance: migration-plan §4 row and/or `schema.rs` line range | +| `notes` | caveats, "make ruff smarter" markers, discrepancy pointers | + +`kind` legend: + +- **`direct`** — the ORM shape *determines* the output; no inference. (A typed + column is a field; a `t.references` DSL call is a declared association.) +- **`guess`** — a name/shape heuristic infers an AR declaration that is + *usually* right but is not stated by the source (a bare `_id` column + probably means `belongs_to :x`). Must clear the oracle before it is trusted. +- **`weak`** — a low-confidence structural hint (`lft`/`rgt` ⇒ nested-set; + `parent_id` ⇒ tree). Never shipped without an oracle confirmation and, + usually, corroborating AR code from ruff. + +## 5. Provenance of `op-codegen-residual` (a DIFFERENT residual — later step) + +Migration plan §5 step 6 retires `op-codegen-residual` and says "its data +moves to `.claude/harvest`". **That is a later step, and its data is a +different concern from this file** — it is NOT folded in here, for two +reasons: + +1. **Different axis.** `op-codegen-residual`'s `RESIDUAL_MANIFEST` + (`crates/op-codegen-residual/src/lib.rs`; doctrine in + `.claude/knowledge/RESIDUAL-THREE-BUCKETS.md`) is the **output-side** + three-buckets doctrine: which *emitted* fields the extractor cannot + determine (`TYPE option`) and which bucket (B1 fuzzy / B2 landing-zone + / B3 manual) each lands in. This file is the **input-side** back-projection: + ORM/DB shape → guessed AR declaration. Its rows (`model, field, bucket, + zone, mint`) are not ORM→AR back-projection rules and do not belong in + `orm-ar-backprojection.toml`. +2. **Additive-only mandate.** This step touches nothing outside + `.claude/harvest/`. `op-codegen-residual` is left exactly as it is; its + crate and tests are untouched. + +So the residual manifest is row-shaped data, but it lives — for now — in the +crate and the knowledge doc named above. Its migration into `.claude/harvest/` +(as its own file, under its own concern) is a later step of the plan, not part +of standing up the ORM→AR back-projection config. + +## 6. §4-table vs `schema.rs` discrepancies (recorded for the oracle pass) + +Cross-checking the migration-plan §4 table against what +`vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/schema.rs` (D-AR-3.5) +actually implements surfaced these gaps. Every one is reflected in the TOML +(extra rules and/or `notes`): + +1. **`schema.rs` implements DIRECT association extraction the §4 table omits.** + `t.references :x` / `t.belongs_to :x` (and `polymorphic: true`) are + *declared* associations in the migration DSL (`schema.rs:215-223`), a + higher-confidence `direct` signal than the §4 name-pattern `guess` + (`_id` ⇒ `belongs_to`). Both are kept: the direct DSL rule fires when the + migration uses `t.references`; the name-pattern guess is the fallback for a + bare `_id` column with no covering DSL call. +2. **`schema.rs` implements structural defaults §4 omits** — the implicit + primary key (`create_table` without `id: false` ⇒ `id` bigint NOT NULL, + `schema.rs:184-187`) and the `t.timestamps` pair + (`created_at`/`updated_at`, `schema.rs:207-211`). Added as `direct` rules. +3. **`schema.rs` deliberately SKIPS index/constraint lines.** + `t.index` / `t.foreign_key` / `t.check_constraint` / + `t.exclusion_constraint` are treated as "constraint/index facts, not + columns" (`schema.rs:204-205`, docstring lines 32-34). So the §4 + `add_index unique ⇒ uniqueness` guess has **no backing harvest yet** — the + unique-index facts are not lifted. That rule's `notes` marks it as a "make + ruff smarter" item (ruff must lift index facts / replay incrementals first). +4. **`schema.rs` is baseline-only** (`columns_from = "baseline-only"`; + incremental `add_column`/`rename_column`/`add_index` after the squash are + not replayed, `schema.rs:27-29`). The unique-index guess and any + post-squash column additions depend on ruff replaying incremental + migrations — an upstream item, noted on the affected rules. +5. **§4 collapses `lft/rgt | parent_id` into one row**; the TOML splits it into + two distinct `weak` rules (`nested_set` from `lft`+`rgt`; `adjacency_tree` + from a self-referential `parent_id`) because they are separate shapes with + separate AR back-projections. `schema.rs` harvests neither specially — they + arrive as plain integer columns. +6. **`schema.rs` harvests columns/tables but does not emit the AR behaviour.** + `t.references` yields the `_id`/`_type` *columns* (not the `belongs_to` + *declaration*); join tables land in `unmatched_tables` + (`schema.rs:31,127-129`) but no HABTM is emitted; an `_count` column + is just an integer field. The association / HABTM / counter_cache + back-projections are exactly what this config adds on top of the harvest. diff --git a/.claude/harvest/orm-ar-backprojection.toml b/.claude/harvest/orm-ar-backprojection.toml new file mode 100644 index 0000000..36b987c --- /dev/null +++ b/.claude/harvest/orm-ar-backprojection.toml @@ -0,0 +1,212 @@ +# ORM -> ActiveRecord back-projection resolver config. +# +# The one training wheel op-nexgen owns (migration plan sec.4 + sec.5 step 2). +# Data, not code: each [[rule]] maps an ORM/DB shape (a Rails migration DSL +# line or a harvested column/table shape) to a guessed AR declaration. +# +# See README.md in this directory for the doctrine, the field schema, the +# kind/validation legends, and the sec.4-vs-schema.rs discrepancy log. +# +# Provenance keys used in `source`: +# plan sec.4 rowN = the row of the sec.4 table in +# .claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md +# schema.rs LxxxLyy = vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/schema.rs +# (the D-AR-3.5 harvest) +# +# NOTHING here is oracle-validated yet: every rule is validation = "unmeasured" +# pending the AR-oracle pass (the 90/10 oracle-diff discipline, plan sec.4c). + +[meta] +schema_version = 1 +purpose = "ORM/DB shape -> guessed ActiveRecord declaration (back-projection)" +data_not_code = "config is data; where data is insufficient, make ruff smarter (spec it, don't fake it here)" +measure_dont_claim = "every guess is validated against the AR oracle; no shipped coverage that wasn't measured" +kinds = ["direct", "guess", "weak"] +validation_states = ["unmeasured", "confirmed", "corrected", "retired"] +# All rules start unmeasured; the AR-oracle pass moves each to a measured state. +default_validation = "unmeasured" +# The closed column-type vocabulary the harvest recognises (schema.rs L81-L99). +column_types = [ + "string", "text", "integer", "bigint", "boolean", "datetime", "date", + "float", "decimal", "jsonb", "json", "uuid", "interval", "tsvector", + "tstzrange", "binary", "timestamp", +] + +# --------------------------------------------------------------------------- +# DIRECT rules -- the ORM shape determines the output; no inference. +# --------------------------------------------------------------------------- + +[[rule]] +id = "typed_column" +input_name = "typed column" +input_pattern = "migration DSL line `t. :name, opts` where is in meta.column_types" +input_regex = '^t\.(string|text|integer|bigint|boolean|datetime|date|float|decimal|jsonb|json|uuid|interval|tsvector|tstzrange|binary|timestamp)\s+:(?P\w+)' +guessed_output = 'Field { name = "", field_type = "" }' +kind = "direct" +validation = "unmeasured" +source = "plan sec.4 row1; schema.rs L234-L238 (COLUMN_TYPES L81-L99)" +notes = "field_type carries the DSL method name verbatim; consumers own the SQL/SurrealQL mapping." + +[[rule]] +id = "explicit_column" +input_name = "explicit column form" +input_pattern = "migration DSL line `t.column :name, :type, opts` (the explicit two-symbol form)" +input_regex = '^t\.column\s+:(?P\w+)\s*,\s*:(?P\w+)' +guessed_output = 'Field { name = "", field_type = "" }' +kind = "direct" +validation = "unmeasured" +source = "schema.rs L225-L232 (variant of plan sec.4 row1; sec.4 table omits the explicit form)" +notes = "Same output shape as typed_column; distinct input syntax." + +[[rule]] +id = "implicit_pk" +input_name = "implicit primary key" +input_pattern = "a `create_table` / `create_unlogged_table` call WITHOUT `id: false`" +input_regex = '^create_(?:unlogged_)?table\b(?!.*id:\s*false)' +guessed_output = 'Field { name = "id", field_type = "bigint", not_null = true }' +kind = "direct" +validation = "unmeasured" +source = "schema.rs L184-L187 (plan sec.4 table omits it)" +notes = "Rails adds an implicit bigint PK unless the create_table opts out via `id: false`." + +[[rule]] +id = "id_false_suppresses_pk" +input_name = "id: false modifier" +input_pattern = "`create_table ..., id: false` -- suppresses the implicit primary key" +input_regex = '^create_(?:unlogged_)?table\b.*id:\s*false' +guessed_output = "no implicit `id` column emitted (modifier on implicit_pk)" +kind = "direct" +validation = "unmeasured" +source = "schema.rs L185 (guard on implicit_pk)" +notes = "Modifier, not a standalone association. Common on 2-FK HABTM join tables (see habtm_join_table)." + +[[rule]] +id = "timestamps_pair" +input_name = "timestamps pair" +input_pattern = "migration DSL line `t.timestamps [precision: nil,] [null: ]`" +input_regex = '^t\.timestamps\b' +guessed_output = 'Field { name = "created_at", field_type = "datetime" } + Field { name = "updated_at", field_type = "datetime" }' +kind = "direct" +validation = "unmeasured" +source = "schema.rs L207-L211 (plan sec.4 table omits it)" +notes = "not_null on both follows the line's `null:` option (default nullable). Pairs with AR's automatic timestamp maintenance." + +[[rule]] +id = "references_belongs_to" +input_name = "declared belongs_to (DSL)" +input_pattern = "migration DSL line `t.references :x` / `t.belongs_to :x` (alias), no `polymorphic: true`" +input_regex = '^t\.(?:references|belongs_to)\s+:(?P\w+)(?!.*polymorphic:\s*true)' +guessed_output = 'belongs_to : + Field { name = "_id", field_type = "bigint" }' +kind = "direct" +validation = "unmeasured" +source = "schema.rs L215-L223 (plan sec.4 table has only the name-pattern GUESS, foreign_key_name)" +notes = "DISCREPANCY 1: the DSL DECLARES the association -> direct. schema.rs emits only the _id column today; the belongs_to declaration is the back-projection this config adds. Fires before foreign_key_name; the guess is the fallback for a bare _id with no covering t.references." + +[[rule]] +id = "references_polymorphic" +input_name = "declared polymorphic belongs_to (DSL)" +input_pattern = "migration DSL line `t.references :x, polymorphic: true` (alias `t.belongs_to`)" +input_regex = '^t\.(?:references|belongs_to)\s+:(?P\w+).*polymorphic:\s*true' +guessed_output = 'belongs_to :, polymorphic: true + Field "_id" bigint + Field "_type" string' +kind = "direct" +validation = "unmeasured" +source = "schema.rs L218-L221 (plan sec.4 table has only the name-pattern GUESS, polymorphic_pair)" +notes = "DISCREPANCY 1: DSL states polymorphic -> direct. schema.rs emits the _id/_type columns; the polymorphic belongs_to declaration is the added back-projection." + +# --------------------------------------------------------------------------- +# GUESS rules -- name/shape heuristics; usually right, not stated by source. +# Must clear the AR oracle before they are trusted. +# --------------------------------------------------------------------------- + +[[rule]] +id = "null_false_presence" +input_name = "not-null -> presence validation" +input_pattern = "any column line carrying `null: false` (harvested Field.not_null == true)" +input_regex = 'null:\s*false' +guessed_output = 'required column + validates :, presence: true' +kind = "guess" +validation = "unmeasured" +source = "plan sec.4 row2 (marked 'direct -> guess'); schema.rs L269-L271 (parse_not_null)" +notes = "The not_null COLUMN fact is direct (harvested); the `validates presence` back-projection is a GUESS -- a DB NOT NULL is not necessarily an AR presence validation. Confirm against actual AR code (a 'make ruff smarter' candidate)." + +[[rule]] +id = "foreign_key_name" +input_name = "foreign-key name -> belongs_to" +input_pattern = "harvested integer/bigint Field named `_id`, with NO covering t.references DSL and NO sibling `_type`" +input_regex = '^(?P\w+)_id$' +guessed_output = "belongs_to :" +kind = "guess" +validation = "unmeasured" +source = "plan sec.4 row3" +notes = "Fallback for a bare _id column. Suppressed when references_belongs_to already fired for , or when polymorphic_pair matches (a _type sibling exists). Do not emit if the association is already AR-declared -- ruff owns that check." + +[[rule]] +id = "polymorphic_pair" +input_name = "id+type pair -> polymorphic belongs_to" +input_pattern = "harvested Fields `_id` AND `_type` on the same model, with no covering polymorphic DSL" +input_regex = '^(?P\w+)_id$ && ^(?P\w+)_type$' +guessed_output = "belongs_to :, polymorphic: true" +kind = "guess" +validation = "unmeasured" +source = "plan sec.4 row4" +notes = "The _id + _type name pair is the PolyRef substrate declaring itself (RESIDUAL-THREE-BUCKETS.md sec.4a). Fallback for when references_polymorphic did not fire." + +[[rule]] +id = "unique_index" +input_name = "unique index -> uniqueness validation" +input_pattern = "an `add_index ..., unique: true` or `t.index ..., unique: true` targeting column(s) c" +input_regex = '(?:add_index|t\.index)\b.*unique:\s*true' +guessed_output = "validates :, uniqueness: true" +kind = "guess" +validation = "unmeasured" +source = "plan sec.4 row5" +notes = "DISCREPANCY 3+4: schema.rs SKIPS index lines (L204-L205, docstring L32-L34) and is baseline-only (L27-L29), so unique-index facts are NOT harvested yet. This rule has no backing harvest until ruff lifts index facts / replays incremental migrations -- a 'make ruff smarter' upstream item, not a heuristic to fake here." + +[[rule]] +id = "habtm_join_table" +input_name = "2-FK join table -> HABTM both sides" +input_pattern = "a table named `a_b` with exactly two `_id` FK columns and no matching AR model (lands in SchemaReport.unmatched_tables)" +input_regex = "" +guessed_output = "has_and_belongs_to_many on both referenced models (A <-> B)" +kind = "guess" +validation = "unmeasured" +source = "plan sec.4 row6" +notes = "DISCREPANCY 6: schema.rs records the join table in unmatched_tables (L31, L127-L129) but emits no HABTM. Often id: false (see id_false_suppresses_pk). The both-sides HABTM declaration is the back-projection this config adds." + +[[rule]] +id = "counter_cache" +input_name = "counter column -> counter_cache" +input_pattern = "harvested integer Field named `_count`, where matches a belongs_to on the model" +input_regex = '^(?P\w+)_count$' +guessed_output = "belongs_to :, counter_cache: true" +kind = "guess" +validation = "unmeasured" +source = "plan sec.4 row7" +notes = "DISCREPANCY 6: schema.rs harvests _count as a plain integer column; the counter_cache back-projection onto the belongs_to is added here. Guess only fires when a corresponding association is present." + +# --------------------------------------------------------------------------- +# WEAK rules -- low-confidence structural hints. Never shipped without an +# oracle confirmation and (usually) corroborating AR code. +# --------------------------------------------------------------------------- + +[[rule]] +id = "nested_set" +input_name = "lft/rgt -> nested set" +input_pattern = "harvested Fields include BOTH `lft` and `rgt` (optionally a `depth`) on one model" +input_regex = '^(?:lft|rgt)$' +guessed_output = "nested-set tree (acts_as_nested_set / awesome_nested_set)" +kind = "weak" +validation = "unmeasured" +source = "plan sec.4 row8 (split: nested-set half)" +notes = "DISCREPANCY 5: sec.4 collapses lft/rgt | parent_id into one row; split here into nested_set vs adjacency_tree. schema.rs harvests lft/rgt as plain integer columns -- no special handling. Weak: confirm against AR code before trusting." + +[[rule]] +id = "adjacency_tree" +input_name = "parent_id -> tree" +input_pattern = "harvested self-referential Field `parent_id` (references the same table)" +input_regex = '^parent_id$' +guessed_output = "adjacency-list tree (acts_as_tree; belongs_to :parent / has_many :children, self-referential)" +kind = "weak" +validation = "unmeasured" +source = "plan sec.4 row8 (split: tree half)" +notes = "DISCREPANCY 5: the tree half of the collapsed sec.4 row8. `parent_id` also matches foreign_key_name; the tree reading is the weaker, self-referential interpretation and must be corroborated (self-reference + child collection) before shipping." From a561b41aec9a71a443d49b721674cf4cbcd97411 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 10:30:14 +0000 Subject: [PATCH 2/5] =?UTF-8?q?unvendor:=20retire=20vendor/AdaWorldAPI-ruf?= =?UTF-8?q?f=20=E2=80=94=20git-dep=20ruff=20at=20the=20upstreamed=20rev=20?= =?UTF-8?q?(consumer-migration=20=C2=A75=20step=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendor fork's entire ahead-delta (D-AR-3.5 schema stratum, Ruby method-visibility filtering, extract_tree_with DO-arm harvest, Predicate::ColumnNotNull + Model.inherits/Field.not_null) landed upstream as AdaWorldAPI/ruff 8d6c31b (strict-superset audit + 66/66 + 47/47 green there), so the vendored ruff_spo_triplet/ruff_ruby_spo retire in favor of rev-pinned git deps — the same pin ritual this repo already runs for OGAR. ruff_openproject (OP-local curation, never an upstream crate) relocates to crates/ as a workspace member with its fixtures made crate-local; ruff_python_dto_check (standalone, no manifest) relocates unmodified. The workspace exclude for the vendor mirror goes away with the mirror. Per the vendored-tree fuse doctrine (OGAR EPIPHANIES E-VENDOR-DELTA-IS-THE-TRAINING-WHEEL): a vendored tree gets a drift fuse or a deletion date — this is the deletion date. Drift protection is now the rev pin + the lock-pin bump ritual. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01D9qw3LYDprZi4uts2m57iq --- ...6-07-05-ogar-v3-consumer-migration-plan.md | 11 +- .gitignore | 1 - Cargo.lock | 2 + Cargo.toml | 11 +- crates/op-codegen-pipeline/Cargo.toml | 6 +- .../tests/rails_fixture.rs | 8 +- .../ruff_openproject/Cargo.toml | 7 +- .../ruff_openproject/src/lib.rs | 0 .../ruff_openproject/tests/extract_test.rs | 13 +- .../openproject/app/models/time_entry.rb | 0 .../openproject/app/models/work_package.rb | 0 .../tests/fixtures/openproject/db/schema.rb | 0 .../ruff_python_dto_check/SQLX-TARGET.md | 0 .../examples/openproject-axum-sqlx.toml | 0 .../src/codegen/sqlx_emit/ajax_json.rs | 0 .../src/codegen/sqlx_emit/csrf_form_post.rs | 0 .../codegen/sqlx_emit/detail_for_tenant.rs | 0 .../src/codegen/sqlx_emit/list_for_tenant.rs | 0 .../src/codegen/sqlx_emit/mod.rs | 0 .../src/codegen/sqlx_emit/soft_delete.rs | 0 .../codegen/sqlx_emit/toggle_bool_field.rs | 0 .../src/codegen/target.rs | 0 .../codegen/sqlx/expected/ajax_json_stub.rs | 0 .../sqlx/expected/ajax_json_with_model.rs | 0 .../sqlx/expected/csrf_form_post_stub.rs | 0 .../expected/csrf_form_post_with_model.rs | 0 .../sqlx/expected/detail_for_tenant.rs | 0 .../codegen/sqlx/expected/list_for_tenant.rs | 0 .../codegen/sqlx/expected/soft_delete.rs | 0 .../sqlx/expected/toggle_bool_field.rs | 0 .../tests/sqlx_emit_ajax_json_test.rs | 0 .../tests/sqlx_emit_csrf_form_post_test.rs | 0 .../tests/sqlx_emit_test.rs | 0 .../tests/sqlx_target_spec_test.rs | 0 vendor/AdaWorldAPI-ruff/Cargo.toml | 69 - .../D-AR-3.5-column-stratum.diff | 642 ---- vendor/AdaWorldAPI-ruff/README.md | 132 - vendor/AdaWorldAPI-ruff/codegen-mod-rs.diff | 53 - .../crates/ruff_ruby_spo/Cargo.toml | 24 - .../crates/ruff_ruby_spo/RUBY-FRONTEND.md | 161 - .../crates/ruff_ruby_spo/src/functions.rs | 1014 ------- .../crates/ruff_ruby_spo/src/lib.rs | 1265 -------- .../crates/ruff_ruby_spo/src/parse.rs | 285 -- .../crates/ruff_ruby_spo/src/schema.rs | 500 ---- .../crates/ruff_ruby_spo/src/walk.rs | 677 ----- .../crates/ruff_spo_triplet/Cargo.toml | 18 - .../SPO_TRIPLET_EXTRACTION.md | 252 -- .../crates/ruff_spo_triplet/src/expand.rs | 2652 ----------------- .../crates/ruff_spo_triplet/src/ir.rs | 766 ----- .../crates/ruff_spo_triplet/src/lib.rs | 121 - .../crates/ruff_spo_triplet/src/ndjson.rs | 186 -- .../crates/ruff_spo_triplet/src/reassemble.rs | 663 ----- .../crates/ruff_spo_triplet/src/triple.rs | 1094 ------- 53 files changed, 26 insertions(+), 10607 deletions(-) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_openproject/Cargo.toml (67%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_openproject/src/lib.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_openproject/tests/extract_test.rs (87%) rename {vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo => crates/ruff_openproject}/tests/fixtures/openproject/app/models/time_entry.rb (100%) rename {vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo => crates/ruff_openproject}/tests/fixtures/openproject/app/models/work_package.rb (100%) rename {vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo => crates/ruff_openproject}/tests/fixtures/openproject/db/schema.rb (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/SQLX-TARGET.md (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/examples/openproject-axum-sqlx.toml (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/src/codegen/sqlx_emit/ajax_json.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/src/codegen/sqlx_emit/csrf_form_post.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/src/codegen/sqlx_emit/detail_for_tenant.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/src/codegen/sqlx_emit/list_for_tenant.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/src/codegen/sqlx_emit/mod.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/src/codegen/sqlx_emit/soft_delete.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/src/codegen/sqlx_emit/toggle_bool_field.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/src/codegen/target.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/ajax_json_stub.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/ajax_json_with_model.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/csrf_form_post_stub.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/csrf_form_post_with_model.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/detail_for_tenant.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/list_for_tenant.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/soft_delete.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/toggle_bool_field.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/sqlx_emit_ajax_json_test.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/sqlx_emit_csrf_form_post_test.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/sqlx_emit_test.rs (100%) rename {vendor/AdaWorldAPI-ruff/crates => crates}/ruff_python_dto_check/tests/sqlx_target_spec_test.rs (100%) delete mode 100644 vendor/AdaWorldAPI-ruff/Cargo.toml delete mode 100644 vendor/AdaWorldAPI-ruff/D-AR-3.5-column-stratum.diff delete mode 100644 vendor/AdaWorldAPI-ruff/README.md delete mode 100644 vendor/AdaWorldAPI-ruff/codegen-mod-rs.diff delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/Cargo.toml delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/RUBY-FRONTEND.md delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/functions.rs delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/lib.rs delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/parse.rs delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/schema.rs delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/walk.rs delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/Cargo.toml delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/SPO_TRIPLET_EXTRACTION.md delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/expand.rs delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/ir.rs delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/lib.rs delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/ndjson.rs delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/reassemble.rs delete mode 100644 vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/triple.rs diff --git a/.claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md b/.claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md index 62bf950..547830b 100644 --- a/.claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md +++ b/.claude/handovers/2026-07-05-ogar-v3-consumer-migration-plan.md @@ -92,10 +92,13 @@ vendored ruff. It becomes **resolver config (data)** in `.claude/harvest/`: ## 5. Sequenced migration (execute after this PR; token-walled here) 1. **[DONE]** un-vendor lance-graph + OGAR → git deps (this PR). -2. Stand up `.claude/harvest/` — the ORM→AR back-projection resolver config - (data) + README. *(additive, zero-risk — the correct first step)* -3. Un-vendor ruff → git dep: retire the D-AR-3.5 patch; the back-projection - moves to §4; cross-ref rows → ruff spec. `vendor/` retires entirely. +2. **[DONE]** Stand up `.claude/harvest/` — the ORM→AR back-projection resolver + config (data) + README. *(landed 4102eb0)* +3. **[DONE]** Un-vendor ruff → git dep: retire the D-AR-3.5 patch; the + back-projection moves to §4; cross-ref rows → ruff spec. `vendor/` retires + entirely. *(this commit; upstream-first landed as ruff 8d6c31b — schema + stratum, visibility filtering, tree harvest, ColumnNotNull/inherits; the + D-AR-3.5 mechanism lives in ruff, the guess-rules live in .claude/harvest)* 4. Add op-nexgen deps: `ogar-from-ruff`, `ogar-adapter-surrealql`, `ogar-emitter` (git deps, same as ogar-vocab today). 5. Rewire `op-codegen-pipeline` → consumer: `ogar-from-ruff` → Class → diff --git a/.gitignore b/.gitignore index 06a30bf..4acf81d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ target/ # Sprint C0 orchestration logs / A2A bus (ephemeral) .sprint/ -vendor/AdaWorldAPI-ruff/Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index be3719c..eb29426 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2278,6 +2278,7 @@ dependencies = [ [[package]] name = "ruff_ruby_spo" version = "0.1.0" +source = "git+https://github.com/AdaWorldAPI/ruff?rev=8d6c31b0ece3485075b591e8ead819f2a7ddf813#8d6c31b0ece3485075b591e8ead819f2a7ddf813" dependencies = [ "lib-ruby-parser", "ruff_spo_triplet", @@ -2286,6 +2287,7 @@ dependencies = [ [[package]] name = "ruff_spo_triplet" version = "0.1.0" +source = "git+https://github.com/AdaWorldAPI/ruff?rev=8d6c31b0ece3485075b591e8ead819f2a7ddf813#8d6c31b0ece3485075b591e8ead819f2a7ddf813" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b0b462e..85f66b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,16 +23,7 @@ members = [ "crates/op-codegen-projection", "crates/op-codegen-pipeline", "crates/op-surreal-ast", -] - -# Vendor mirrors have their own standalone workspaces (see -# vendor/*/Cargo.toml). Excluding them stops cargo from sucking their member -# crates into nexgen's workspace — which otherwise breaks `workspace = true` -# inheritance in those crates' manifests (they inherit from the vendor -# workspace, not from nexgen). Path-deps into the vendor still work -# normally; they bypass workspace-member discovery. -exclude = [ - "vendor/AdaWorldAPI-ruff", + "crates/ruff_openproject", ] [workspace.package] diff --git a/crates/op-codegen-pipeline/Cargo.toml b/crates/op-codegen-pipeline/Cargo.toml index af31150..3460160 100644 --- a/crates/op-codegen-pipeline/Cargo.toml +++ b/crates/op-codegen-pipeline/Cargo.toml @@ -11,9 +11,9 @@ description = "OpenProject end-to-end codegen pipeline: Rails IR → ruff SPO tr name = "op_codegen_pipeline" [dependencies] -ruff_openproject = { path = "../../vendor/AdaWorldAPI-ruff/crates/ruff_openproject" } -ruff_spo_triplet = { path = "../../vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet" } -ruff_ruby_spo = { path = "../../vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo" } +ruff_openproject = { path = "../ruff_openproject" } +ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", rev = "8d6c31b0ece3485075b591e8ead819f2a7ddf813" } +ruff_ruby_spo = { git = "https://github.com/AdaWorldAPI/ruff", rev = "8d6c31b0ece3485075b591e8ead819f2a7ddf813" } op-codegen-projection = { path = "../op-codegen-projection" } op-surreal-ast = { path = "../op-surreal-ast" } lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main" } diff --git a/crates/op-codegen-pipeline/tests/rails_fixture.rs b/crates/op-codegen-pipeline/tests/rails_fixture.rs index aedb65e..3b79f0a 100644 --- a/crates/op-codegen-pipeline/tests/rails_fixture.rs +++ b/crates/op-codegen-pipeline/tests/rails_fixture.rs @@ -5,9 +5,11 @@ //! //! The fixture lives at `tests/fixtures/rails_mini/` — minimal, //! hermetic, version-controlled. It is NOT a copy of the -//! `vendor/AdaWorldAPI-ruff/.../tests/fixtures/openproject` fixture: -//! upstream may evolve that one; this one is owned by this crate so its -//! contents pin exactly the cases we want to demonstrate end-to-end: +//! `ruff_openproject/tests/fixtures/openproject` fixture (itself a +//! crate-local copy of the upstream ruff fixture, since 2026-07-05's +//! un-vendor): upstream may evolve that one; this one is owned by this +//! crate so its contents pin exactly the cases we want to demonstrate +//! end-to-end: //! //! - 2 core models (WorkPackage, TimeEntry) — both in //! `CORE_V3_RESOURCES`, exercising the core-filter path diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_openproject/Cargo.toml b/crates/ruff_openproject/Cargo.toml similarity index 67% rename from vendor/AdaWorldAPI-ruff/crates/ruff_openproject/Cargo.toml rename to crates/ruff_openproject/Cargo.toml index 62ed046..12f6221 100644 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_openproject/Cargo.toml +++ b/crates/ruff_openproject/Cargo.toml @@ -11,8 +11,5 @@ license = "MIT" name = "ruff_openproject" [dependencies] -ruff_ruby_spo = { workspace = true } -ruff_spo_triplet = { workspace = true } - -[lints] -workspace = true +ruff_ruby_spo = { git = "https://github.com/AdaWorldAPI/ruff", rev = "8d6c31b0ece3485075b591e8ead819f2a7ddf813" } +ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", rev = "8d6c31b0ece3485075b591e8ead819f2a7ddf813" } diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_openproject/src/lib.rs b/crates/ruff_openproject/src/lib.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_openproject/src/lib.rs rename to crates/ruff_openproject/src/lib.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_openproject/tests/extract_test.rs b/crates/ruff_openproject/tests/extract_test.rs similarity index 87% rename from vendor/AdaWorldAPI-ruff/crates/ruff_openproject/tests/extract_test.rs rename to crates/ruff_openproject/tests/extract_test.rs index 934d46d..9bd256a 100644 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_openproject/tests/extract_test.rs +++ b/crates/ruff_openproject/tests/extract_test.rs @@ -1,6 +1,5 @@ -//! End-to-end test: drive `ruff_openproject::extract_*` over the shared -//! Rails fixture (reused from `ruff_ruby_spo`'s test tree to avoid -//! duplication) and assert the OpenProject-shaped output. +//! End-to-end test: drive `ruff_openproject::extract_*` over the OpenProject +//! Rails fixture and assert the OpenProject-shaped output. use std::path::PathBuf; @@ -10,11 +9,11 @@ use ruff_openproject::{ }; fn fixture_tree() -> PathBuf { - // Reuse the existing fixture under ruff_ruby_spo (one tree, two test - // suites — no copy, no drift). + // Crate-local copy of the fixture originally shared with ruff_ruby_spo's + // test tree (unvendored 2026-07-05 — ruff_ruby_spo now resolves via a + // pinned git dep, so its test fixtures are no longer reachable by + // relative path; the fixture is duplicated here instead of drifting). PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("ruff_ruby_spo") .join("tests") .join("fixtures") .join("openproject") diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/tests/fixtures/openproject/app/models/time_entry.rb b/crates/ruff_openproject/tests/fixtures/openproject/app/models/time_entry.rb similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/tests/fixtures/openproject/app/models/time_entry.rb rename to crates/ruff_openproject/tests/fixtures/openproject/app/models/time_entry.rb diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/tests/fixtures/openproject/app/models/work_package.rb b/crates/ruff_openproject/tests/fixtures/openproject/app/models/work_package.rb similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/tests/fixtures/openproject/app/models/work_package.rb rename to crates/ruff_openproject/tests/fixtures/openproject/app/models/work_package.rb diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/tests/fixtures/openproject/db/schema.rb b/crates/ruff_openproject/tests/fixtures/openproject/db/schema.rb similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/tests/fixtures/openproject/db/schema.rb rename to crates/ruff_openproject/tests/fixtures/openproject/db/schema.rb diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/SQLX-TARGET.md b/crates/ruff_python_dto_check/SQLX-TARGET.md similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/SQLX-TARGET.md rename to crates/ruff_python_dto_check/SQLX-TARGET.md diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/examples/openproject-axum-sqlx.toml b/crates/ruff_python_dto_check/examples/openproject-axum-sqlx.toml similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/examples/openproject-axum-sqlx.toml rename to crates/ruff_python_dto_check/examples/openproject-axum-sqlx.toml diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/ajax_json.rs b/crates/ruff_python_dto_check/src/codegen/sqlx_emit/ajax_json.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/ajax_json.rs rename to crates/ruff_python_dto_check/src/codegen/sqlx_emit/ajax_json.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/csrf_form_post.rs b/crates/ruff_python_dto_check/src/codegen/sqlx_emit/csrf_form_post.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/csrf_form_post.rs rename to crates/ruff_python_dto_check/src/codegen/sqlx_emit/csrf_form_post.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/detail_for_tenant.rs b/crates/ruff_python_dto_check/src/codegen/sqlx_emit/detail_for_tenant.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/detail_for_tenant.rs rename to crates/ruff_python_dto_check/src/codegen/sqlx_emit/detail_for_tenant.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/list_for_tenant.rs b/crates/ruff_python_dto_check/src/codegen/sqlx_emit/list_for_tenant.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/list_for_tenant.rs rename to crates/ruff_python_dto_check/src/codegen/sqlx_emit/list_for_tenant.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/mod.rs b/crates/ruff_python_dto_check/src/codegen/sqlx_emit/mod.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/mod.rs rename to crates/ruff_python_dto_check/src/codegen/sqlx_emit/mod.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/soft_delete.rs b/crates/ruff_python_dto_check/src/codegen/sqlx_emit/soft_delete.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/soft_delete.rs rename to crates/ruff_python_dto_check/src/codegen/sqlx_emit/soft_delete.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/toggle_bool_field.rs b/crates/ruff_python_dto_check/src/codegen/sqlx_emit/toggle_bool_field.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/sqlx_emit/toggle_bool_field.rs rename to crates/ruff_python_dto_check/src/codegen/sqlx_emit/toggle_bool_field.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/target.rs b/crates/ruff_python_dto_check/src/codegen/target.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/src/codegen/target.rs rename to crates/ruff_python_dto_check/src/codegen/target.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/ajax_json_stub.rs b/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/ajax_json_stub.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/ajax_json_stub.rs rename to crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/ajax_json_stub.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/ajax_json_with_model.rs b/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/ajax_json_with_model.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/ajax_json_with_model.rs rename to crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/ajax_json_with_model.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/csrf_form_post_stub.rs b/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/csrf_form_post_stub.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/csrf_form_post_stub.rs rename to crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/csrf_form_post_stub.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/csrf_form_post_with_model.rs b/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/csrf_form_post_with_model.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/csrf_form_post_with_model.rs rename to crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/csrf_form_post_with_model.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/detail_for_tenant.rs b/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/detail_for_tenant.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/detail_for_tenant.rs rename to crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/detail_for_tenant.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/list_for_tenant.rs b/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/list_for_tenant.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/list_for_tenant.rs rename to crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/list_for_tenant.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/soft_delete.rs b/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/soft_delete.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/soft_delete.rs rename to crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/soft_delete.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/toggle_bool_field.rs b/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/toggle_bool_field.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/toggle_bool_field.rs rename to crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/toggle_bool_field.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/sqlx_emit_ajax_json_test.rs b/crates/ruff_python_dto_check/tests/sqlx_emit_ajax_json_test.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/sqlx_emit_ajax_json_test.rs rename to crates/ruff_python_dto_check/tests/sqlx_emit_ajax_json_test.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/sqlx_emit_csrf_form_post_test.rs b/crates/ruff_python_dto_check/tests/sqlx_emit_csrf_form_post_test.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/sqlx_emit_csrf_form_post_test.rs rename to crates/ruff_python_dto_check/tests/sqlx_emit_csrf_form_post_test.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/sqlx_emit_test.rs b/crates/ruff_python_dto_check/tests/sqlx_emit_test.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/sqlx_emit_test.rs rename to crates/ruff_python_dto_check/tests/sqlx_emit_test.rs diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/sqlx_target_spec_test.rs b/crates/ruff_python_dto_check/tests/sqlx_target_spec_test.rs similarity index 100% rename from vendor/AdaWorldAPI-ruff/crates/ruff_python_dto_check/tests/sqlx_target_spec_test.rs rename to crates/ruff_python_dto_check/tests/sqlx_target_spec_test.rs diff --git a/vendor/AdaWorldAPI-ruff/Cargo.toml b/vendor/AdaWorldAPI-ruff/Cargo.toml deleted file mode 100644 index f0aca6d..0000000 --- a/vendor/AdaWorldAPI-ruff/Cargo.toml +++ /dev/null @@ -1,69 +0,0 @@ -# vendor/AdaWorldAPI-ruff/Cargo.toml -# -# Standalone workspace root for the vendored ruff crate slice. -# -# SNAPSHOT STATE (2026-07-02): ruff_ruby_spo + ruff_spo_triplet synced to -# upstream `main` HEAD (raw-fetch reassembly; git relay 403s this repo) WITH -# the D-AR-3.5 column-stratum patch applied on top — the full patch against -# pristine main is `D-AR-3.5-column-stratum.diff` (this dir), ready for -# upstream application. ruff_openproject remains at its older branch-era -# snapshot; its locked-shape test (`extract_triples_produces_locked_shape`) -# fails against main's ruby_spo body extraction EVEN WITHOUT the patch -# (verified empirically 2026-07-02: pristine main + that test → same -# failure, same line) — upstream main-vs-branch drift for the ruff session -# to reconcile, not a defect of the sync or the patch. -# -# WHY THIS FILE EXISTS -# Codex PR #6 P1: the three vendored crates (ruff_openproject, ruff_ruby_spo, -# ruff_spo_triplet) each use `{ workspace = true }` for their deps and lints — -# matching the upstream AdaWorldAPI/ruff layout BYTE-EXACT (that's the point of -# a mirror). Without a workspace root here, cargo walks up to nexgen's root -# Cargo.toml, whose [workspace.dependencies] doesn't define ruff_ruby_spo / -# ruff_spo_triplet / serde / serde_json — so the manifests fail to parse under -# nexgen. This file gives the vendor a SEPARATE, nearest-ancestor workspace -# root, supplying exactly the deps + lints those `workspace = true` references -# resolve to. The vendored crate Cargo.tomls themselves stay unchanged from -# upstream. -# -# SCOPE -# This is a parsing/review aid, not a production build. The vendor mirror is -# the source-of-truth for code review on PRs and for upstream re-application; -# `cargo check --manifest-path vendor/AdaWorldAPI-ruff/Cargo.toml` should -# parse cleanly here, but the full upstream ruff workspace (90+ crates) is not -# vendored. To actually develop in the ruff fork, clone AdaWorldAPI/ruff and -# work there — this directory is intentionally a slice. -# -# OUT OF SCOPE -# nexgen's root [workspace] is unaffected. vendor/ is not a member of nexgen's -# workspace (members are explicit, no glob), so the two workspaces do not -# overlap and cargo treats each independently based on which manifest is -# entered. - -[workspace] -resolver = "2" -members = [ - "crates/ruff_openproject", - "crates/ruff_ruby_spo", - "crates/ruff_spo_triplet", -] - -[workspace.dependencies] -# Internal crates, path-resolved within this vendor slice. -ruff_ruby_spo = { path = "crates/ruff_ruby_spo" } -ruff_spo_triplet = { path = "crates/ruff_spo_triplet" } -# External crates the vendor needs. Versions match what upstream ruff's -# root workspace pins (Cargo.toml lines 18-19 at the snapshot mirrored). -serde = { version = "1.0.197", features = ["derive"] } -serde_json = { version = "1.0.113" } -# Sprint C17a: Ruby parser graduation. Pure-Rust typed AST, no Ruby runtime. -# Default features only (`bin-parse` + `codegen-rust` are dev-side). -lib-ruby-parser = { version = "4.0" } - -# Empty lints tables so that `[lints] workspace = true` in the member -# crates resolves to a no-op locally. The actual upstream ruff lints -# (`unsafe_code = "warn"`, the clippy::pedantic set, etc.) are NOT mirrored -# here — vendor parsing is the goal, not enforcing ruff's lint policy on -# what is fundamentally a snapshot. -[workspace.lints.rust] - -[workspace.lints.clippy] diff --git a/vendor/AdaWorldAPI-ruff/D-AR-3.5-column-stratum.diff b/vendor/AdaWorldAPI-ruff/D-AR-3.5-column-stratum.diff deleted file mode 100644 index 900acf2..0000000 --- a/vendor/AdaWorldAPI-ruff/D-AR-3.5-column-stratum.diff +++ /dev/null @@ -1,642 +0,0 @@ ---- a/crates/ruff_spo_triplet/src/ir.rs -+++ b/crates/ruff_spo_triplet/src/ir.rs -@@ -234,6 +234,16 @@ - /// constructor here, so the two predicates never double-emit for one field. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub field_type: Option, -+ /// For a **DB-column** field (D-AR-3.5 schema stratum: extracted from -+ /// the Rails migration DSL, `db/migrate/tables/*.rb`), `Some(true)` -+ /// when the column carries `null: false`. Emitted as -+ /// `(field, column_not_null, "true")` — only for `Some(true)`; `None` -+ /// / `Some(false)` emit nothing (nullable is the Rails default, and -+ /// absence-means-nullable keeps the triple volume proportional to the -+ /// constraint count). Downstream this is the `required` axis of -+ /// `DEFINE FIELD` (`TYPE ` vs `TYPE option`). -+ #[serde(default, skip_serializing_if = "Option::is_none")] -+ pub not_null: Option, - } - - /// One method / function. ---- a/crates/ruff_spo_triplet/src/expand.rs -+++ b/crates/ruff_spo_triplet/src/expand.rs -@@ -222,6 +222,17 @@ - Provenance::Authoritative, - ); - } -+ // Schema stratum (D-AR-3.5): a `null: false` column constraint -+ // from the migration DSL. Only the positive fact is emitted — -+ // absence means nullable (the Rails default). -+ if field.not_null == Some(true) { -+ self.push( -+ field_iri.clone(), -+ Predicate::ColumnNotNull, -+ "true".to_string(), -+ Provenance::Authoritative, -+ ); -+ } - } - - // 3 + 6 + 7 + 8. functions ---- a/crates/ruff_spo_triplet/src/triple.rs -+++ b/crates/ruff_spo_triplet/src/triple.rs -@@ -442,6 +442,22 @@ - /// IRI), emitted verbatim like [`Self::Target`]: the receiver is - /// heuristically resolved, so the default tier is `Inferred`. - Calls, -+ -+ // ───── Schema-stratum extension (D-AR-3.5: migration-DSL columns) ───── -+ // -+ // Physical DB columns extracted from the Rails migration DSL -+ // (`db/migrate/tables/*.rb` squashed baseline). The WorkPackage oracle -+ // diff (op-nexgen `RESIDUAL-THREE-BUCKETS.md` §4c) measured that ~90% -+ // of a hand-written model struct derives from this stratum alone; the -+ // declared type reuses [`Self::FieldType`], so nullability is the one -+ // fact the vocabulary lacked. -+ /// `(model.field, column_not_null, "true")` — the column carries a -+ /// `null: false` constraint in the migration DSL. Emitted ONLY for -+ /// NOT NULL columns; absence means nullable (the Rails default). -+ /// Authoritative: the constraint is a machine-readable DSL literal. -+ /// Downstream this is the `required` axis of `DEFINE FIELD` — NOT -+ /// NULL emits `TYPE `, nullable emits `TYPE option`. -+ ColumnNotNull, - } - - impl Predicate { -@@ -516,6 +532,8 @@ - // Body-mutation extension - Self::WritesField => "writes_field", - Self::Calls => "calls", -+ // Schema-stratum extension -+ Self::ColumnNotNull => "column_not_null", - } - } - -@@ -596,6 +614,8 @@ - // Body-mutation extension - "writes_field" => Self::WritesField, - "calls" => Self::Calls, -+ // Schema-stratum extension -+ "column_not_null" => Self::ColumnNotNull, - _ => return None, - }) - } -@@ -676,6 +696,7 @@ - // Body-mutation extension - Self::WritesField, - Self::Calls, -+ Self::ColumnNotNull, - ]; - - /// The default provenance tier for this predicate, per the Odoo -@@ -769,6 +790,9 @@ - | Self::ClassName - | Self::ValidationKind - | Self::ValidationParam => Provenance::OpenProjectExtracted, -+ // Schema-stratum extension: the constraint is a machine-readable -+ // migration-DSL literal — same certainty tier as `writes_field`. -+ Self::ColumnNotNull => Provenance::Authoritative, - } - } - } -@@ -881,7 +905,7 @@ - } - - #[test] -- fn predicate_count_locked_at_62() { -+ fn predicate_count_locked_at_63() { - // The exact count is part of the schema contract: 7 core (Odoo - // Python) + 32 OpenProject AR-shape (PR #15 added - // `association_kind`; #18 added `class_name`; #21 added -@@ -893,10 +917,13 @@ - // + 2 body-mutation (`writes_field` / `calls` — the command-shape - // counterpart of `reads_field` / `traverses_relation`, capturing - // self-field writes and lifecycle-mutator dispatches so the body-pass -- // triage can split query from command) = 62. Council review of any -+ // triage can split query from command) -+ // + 1 schema-stratum (`column_not_null` — the D-AR-3.5 migration-DSL -+ // column constraint; the declared type reuses `field_type`, so -+ // nullability was the one missing fact) = 63. Council review of any - // new variant means this number changes — the test must change with - // the source. -- assert_eq!(Predicate::ALL.len(), 62); -+ assert_eq!(Predicate::ALL.len(), 63); - } - - #[test] ---- a/crates/ruff_ruby_spo/src/lib.rs -+++ b/crates/ruff_ruby_spo/src/lib.rs -@@ -36,8 +36,11 @@ - - mod functions; - mod parse; -+mod schema; - mod walk; - -+pub use schema::{extract_app_with_schema, SchemaReport}; -+ - /// The namespace prefix for `OpenProject` subjects/objects. - pub const NAMESPACE: &str = "openproject"; - ---- a/crates/ruff_ruby_spo/src/schema.rs -+++ b/crates/ruff_ruby_spo/src/schema.rs -@@ -0,0 +1,500 @@ -+//! **D-AR-3.5** — the schema stratum: physical DB columns from the Rails -+//! migration DSL. -+//! -+//! OpenProject ships no `db/schema.rb` / `db/structure.sql`; the squashed -+//! baseline lives in `db/migrate/tables/*.rb` — one `Tables::X < -+//! Tables::Base` class per table, whose `self.table(migration)` body is a -+//! plain `create_table … do |t| … end` block of `t. :name, opts` -+//! calls. That DSL is a fixed, enumerable vocabulary (22 distinct `t.*` -+//! methods across OpenProject's 99 baseline files), so a line scanner in -+//! the style of [`crate::functions`] extracts it without a Ruby runtime. -+//! -+//! # Why this stratum matters -+//! -+//! The WorkPackage oracle diff (op-nexgen `RESIDUAL-THREE-BUCKETS.md` §4c) -+//! measured that **~90% of a hand-written Rust model struct derives from -+//! the column stratum alone** (name + type + nullability), and the -+//! remaining typings come from validation triples the expander already -+//! ships. The class-body extraction ([`crate::extract_app_with`]) reads -+//! the *method/DSL* stratum; this module supplies the missing *column* -+//! stratum. Columns land as [`Field`]s (`field_type` = the DSL method -+//! name verbatim, `not_null` from `null: false`), so they flow through -+//! the existing `field_type` / `column_not_null` predicates with no new -+//! IR shape. -+//! -+//! # Scope (recorded honestly, conservation-ledger style) -+//! -+//! - **Baseline only**: incremental migrations (`db/migrate/*.rb`, -+//! `modules/*/db/migrate/*.rb`) that `add_column`/`rename_column` after -+//! the squash are NOT replayed. [`SchemaReport::columns_from`] says so. -+//! - Join tables and other tables with no matching AR class are counted in -+//! [`SchemaReport::unmatched_tables`], never silently dropped. -+//! - `t.index` / `t.foreign_key` / `t.check_constraint` / -+//! `t.exclusion_constraint` lines are constraint/index facts, not -+//! columns — skipped here (a later slice can lift them). -+ -+use std::fs; -+use std::path::Path; -+ -+use ruff_spo_triplet::{Field, ModelGraph}; -+ -+/// Conservation-ledger seed for the schema pass: what was seen, what -+/// matched, what didn't — nothing drops silently. -+#[derive(Debug, Clone, Default, PartialEq, Eq)] -+pub struct SchemaReport { -+ /// Baseline table files successfully parsed. -+ pub tables_seen: usize, -+ /// Tables whose inflected model name matched a model in the graph -+ /// (columns merged into that model's `fields`). -+ pub tables_matched: usize, -+ /// Tables with no matching model (join tables, unported domains) — -+ /// named, not just counted. -+ pub unmatched_tables: Vec, -+ /// Files under `db/migrate/tables/` that could not be read or contained -+ /// no recognisable `create_table` block (e.g. `base.rb`, the abstract -+ /// helper) — named, not just counted. -+ pub files_skipped: Vec, -+ /// Provenance marker: which migration surface produced the columns. -+ /// Currently always `"baseline-only"` (no incremental replay). -+ pub columns_from: &'static str, -+} -+ -+/// One parsed baseline table: the physical columns of `table_name`, -+/// plus the Rails-inflected model name they attach to. -+#[derive(Debug, Clone, PartialEq)] -+pub(crate) struct TableColumns { -+ /// The physical table name — the file stem (`work_packages`), which is -+ /// exactly what `Tables::Base.table_name` derives via -+ /// `name.demodulize.underscore`. -+ pub(crate) table_name: String, -+ /// The Rails-conventional model name (`WorkPackage`) — PascalCase -+ /// singular of the table name. -+ pub(crate) model_name: String, -+ /// The columns, in declaration order, as IR fields (`field_type` = -+ /// DSL method name verbatim; `not_null` from `null: false`). -+ pub(crate) fields: Vec, -+} -+ -+/// The `t.` names that declare a typed column directly. The DSL -+/// method name doubles as the emitted `field_type` token (surface label — -+/// consumers own the SQL/SurrealQL mapping). -+const COLUMN_TYPES: &[&str] = &[ -+ "string", -+ "text", -+ "integer", -+ "bigint", -+ "boolean", -+ "datetime", -+ "date", -+ "float", -+ "decimal", -+ "jsonb", -+ "json", -+ "uuid", -+ "interval", -+ "tsvector", -+ "tstzrange", -+ "binary", -+ "timestamp", -+]; -+ -+/// Extract a Rails app **including the schema stratum**: everything -+/// [`crate::extract_app_with`] harvests, plus the baseline DB columns -+/// merged into each model's `fields`, plus a [`SchemaReport`] ledger. -+/// -+/// Column fields are appended to the matching model's `fields` (matched by -+/// Rails inflection of the table name; existing same-name fields, if any -+/// future pass creates them, are not duplicated). Tables with no matching -+/// model are recorded in the report — the join-table population is real -+/// and expected (`changesets_work_packages` et al. have no AR class). -+#[must_use] -+pub fn extract_app_with_schema(source_tree: &Path, namespace: &str) -> (ModelGraph, SchemaReport) { -+ let mut graph = crate::extract_app_with(source_tree, namespace); -+ let mut report = SchemaReport { -+ columns_from: "baseline-only", -+ ..SchemaReport::default() -+ }; -+ -+ for table in parse_tables_dir(source_tree, &mut report) { -+ report.tables_seen += 1; -+ if let Some(model) = graph.models.iter_mut().find(|m| m.name == table.model_name) { -+ report.tables_matched += 1; -+ for field in table.fields { -+ if !model.fields.iter().any(|f| f.name == field.name) { -+ model.fields.push(field); -+ } -+ } -+ } else { -+ report.unmatched_tables.push(table.table_name); -+ } -+ } -+ report.unmatched_tables.sort(); -+ report.files_skipped.sort(); -+ (graph, report) -+} -+ -+/// Parse every baseline table file under `/db/migrate/tables/`. -+/// Deterministic: files are sorted before parsing (same discipline as -+/// [`crate::parse`]'s walk). Unreadable / unrecognisable files land in the -+/// report's `files_skipped`, not on the floor. -+pub(crate) fn parse_tables_dir(source_tree: &Path, report: &mut SchemaReport) -> Vec { -+ let dir = source_tree.join("db/migrate/tables"); -+ let Ok(entries) = fs::read_dir(&dir) else { -+ return Vec::new(); -+ }; -+ let mut files: Vec<_> = entries -+ .flatten() -+ .map(|e| e.path()) -+ .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("rb")) -+ .collect(); -+ files.sort(); -+ -+ let mut tables = Vec::with_capacity(files.len()); -+ for path in files { -+ let stem = path -+ .file_stem() -+ .and_then(|s| s.to_str()) -+ .unwrap_or_default() -+ .to_string(); -+ let Ok(src) = fs::read_to_string(&path) else { -+ report.files_skipped.push(stem); -+ continue; -+ }; -+ match parse_table_source(&stem, &src) { -+ Some(table) => tables.push(table), -+ None => report.files_skipped.push(stem), -+ } -+ } -+ tables -+} -+ -+/// Parse one baseline table file's source. `None` when the file has no -+/// `create_table` block (e.g. `base.rb`, the abstract helper class). -+pub(crate) fn parse_table_source(table_name: &str, src: &str) -> Option { -+ let mut fields: Vec = Vec::new(); -+ let mut in_block = false; -+ let mut saw_create_table = false; -+ -+ for raw in src.lines() { -+ let line = raw.trim(); -+ if !in_block { -+ if line.starts_with("create_table") || line.starts_with("create_unlogged_table") { -+ saw_create_table = true; -+ in_block = true; -+ // Implicit primary key unless the create_table call opts out. -+ if !line.contains("id: false") { -+ fields.push(column_field("id", "bigint", true)); -+ } -+ } -+ continue; -+ } -+ if line == "end" { -+ // First `end` at block depth closes the `do |t|` block. The -+ // baseline files nest nothing deeper inside it. -+ break; -+ } -+ let Some(rest) = line.strip_prefix("t.") else { -+ continue; -+ }; -+ let (method, args) = match rest.split_once(char::is_whitespace) { -+ Some((m, a)) => (m, a.trim()), -+ None => (rest, ""), -+ }; -+ match method { -+ // Constraint / index facts, not columns. -+ "index" | "foreign_key" | "check_constraint" | "exclusion_constraint" => {} -+ // `t.timestamps precision: nil, null: true` → the pair. -+ "timestamps" => { -+ let not_null = parse_not_null(args); -+ fields.push(column_field("created_at", "datetime", not_null)); -+ fields.push(column_field("updated_at", "datetime", not_null)); -+ } -+ // `t.references :x, null: false, polymorphic: true` (alias -+ // `belongs_to`) → `x_id` bigint, plus `x_type` string when -+ // polymorphic. -+ "references" | "belongs_to" => { -+ if let Some(name) = first_symbol_arg(args) { -+ let not_null = parse_not_null(args); -+ fields.push(column_field(&format!("{name}_id"), "bigint", not_null)); -+ if args.contains("polymorphic: true") { -+ fields.push(column_field(&format!("{name}_type"), "string", not_null)); -+ } -+ } -+ } -+ // `t.column :name, :type, opts` — the explicit form. -+ "column" => { -+ let mut symbols = args.split(',').map(str::trim); -+ let name = symbols.next().and_then(symbol_token); -+ let ty = symbols.next().and_then(symbol_token); -+ if let (Some(name), Some(ty)) = (name, ty) { -+ fields.push(column_field(name, ty, parse_not_null(args))); -+ } -+ } -+ // `t. :name, opts` — the direct typed forms. -+ m if COLUMN_TYPES.contains(&m) => { -+ if let Some(name) = first_symbol_arg(args) { -+ fields.push(column_field(name, m, parse_not_null(args))); -+ } -+ } -+ // Unknown t.* method: not a column declaration we recognise. -+ // The closed COLUMN_TYPES list + this arm make additions an -+ // explicit act (same discipline as the Predicate count-lock). -+ _ => {} -+ } -+ } -+ -+ if !saw_create_table { -+ return None; -+ } -+ Some(TableColumns { -+ table_name: table_name.to_string(), -+ model_name: model_name_for_table(table_name), -+ fields, -+ }) -+} -+ -+/// Build one column [`Field`]: `field_type` carries the DSL method name -+/// verbatim; `not_null` only when the DSL says `null: false`. -+fn column_field(name: &str, dsl_type: &str, not_null: bool) -> Field { -+ Field { -+ name: name.to_string(), -+ field_type: Some(dsl_type.to_string()), -+ not_null: if not_null { Some(true) } else { None }, -+ ..Field::default() -+ } -+} -+ -+/// `null: false` anywhere in the arg list → NOT NULL. Rails' default for -+/// columns is nullable, so absence (or explicit `null: true`) is `false`. -+fn parse_not_null(args: &str) -> bool { -+ args.contains("null: false") -+} -+ -+/// The first `:symbol` argument, e.g. `":subject, default: …"` → `subject`. -+fn first_symbol_arg(args: &str) -> Option<&str> { -+ args.split(',').next().and_then(symbol_token) -+} -+ -+/// `":name"` → `name` (with surrounding whitespace tolerated). -+fn symbol_token(part: &str) -> Option<&str> { -+ part.trim().strip_prefix(':').map(|s| s.trim_end()) -+} -+ -+/// Rails inflection, table → model: snake_case plural → PascalCase -+/// singular (`work_packages` → `WorkPackage`). Only the last segment is -+/// singularised. The rule chain covers the OpenProject baseline corpus; -+/// genuinely irregular names belong in `IRREGULAR`, and a miss lands the -+/// table in `unmatched_tables` (visible), never on a wrong model. -+pub(crate) fn model_name_for_table(table: &str) -> String { -+ /// Table names whose singular is not rule-derivable. -+ const IRREGULAR: &[(&str, &str)] = &[("news", "news"), ("meeting_agenda_item_series", "meeting_agenda_item_series")]; -+ -+ let segments: Vec<&str> = table.split('_').collect(); -+ let mut out = String::new(); -+ let last = segments.len().saturating_sub(1); -+ for (i, seg) in segments.iter().enumerate() { -+ let word = if i == last { -+ singularize(seg, table, IRREGULAR) -+ } else { -+ (*seg).to_string() -+ }; -+ let mut chars = word.chars(); -+ if let Some(first) = chars.next() { -+ out.push(first.to_ascii_uppercase()); -+ out.push_str(chars.as_str()); -+ } -+ } -+ out -+} -+ -+/// Singularise one snake_case segment (the table's final word). -+fn singularize(seg: &str, full_table: &str, irregular: &[(&str, &str)]) -> String { -+ if let Some((_, singular)) = irregular.iter().find(|(t, _)| *t == full_table) { -+ return (*singular).to_string(); -+ } -+ if let Some(stem) = seg.strip_suffix("ies") { -+ return format!("{stem}y"); -+ } -+ for es_suffix in ["sses", "shes", "ches", "xes", "zes", "uses"] { -+ if seg.ends_with(es_suffix) { -+ return seg[..seg.len() - 2].to_string(); -+ } -+ } -+ seg.strip_suffix('s').unwrap_or(seg).to_string() -+} -+ -+#[cfg(test)] -+mod tests { -+ use super::*; -+ -+ const SAMPLE: &str = r#" -+class Tables::WorkPackages < Tables::Base -+ def self.table(migration) -+ create_table migration do |t| -+ t.references :type, null: false, index: true, foreign_key: { on_delete: :cascade } -+ t.string :subject, default: "", null: false -+ t.text :description -+ t.integer :done_ratio, default: nil, null: true -+ t.timestamps precision: nil, null: true, index: true -+ t.belongs_to :responsible -+ t.boolean :schedule_manually, default: true, null: false -+ t.references :reactable, polymorphic: true, null: false -+ t.column :builtin, :boolean, default: false, null: false -+ -+ t.index %i[project_id updated_at] -+ t.check_constraint "due_date >= start_date", name: "x" -+ end -+ end -+end -+"#; -+ -+ #[test] -+ fn parses_the_dsl_forms() { -+ let t = parse_table_source("work_packages", SAMPLE).expect("create_table block"); -+ assert_eq!(t.model_name, "WorkPackage"); -+ let names: Vec<&str> = t.fields.iter().map(|f| f.name.as_str()).collect(); -+ assert_eq!( -+ names, -+ [ -+ "id", -+ "type_id", -+ "subject", -+ "description", -+ "done_ratio", -+ "created_at", -+ "updated_at", -+ "responsible_id", -+ "schedule_manually", -+ "reactable_id", -+ "reactable_type", -+ "builtin", -+ ] -+ ); -+ let by_name = |n: &str| t.fields.iter().find(|f| f.name == n).unwrap(); -+ // Implicit PK. -+ assert_eq!(by_name("id").field_type.as_deref(), Some("bigint")); -+ assert_eq!(by_name("id").not_null, Some(true)); -+ // references → _id bigint, null: false honoured. -+ assert_eq!(by_name("type_id").field_type.as_deref(), Some("bigint")); -+ assert_eq!(by_name("type_id").not_null, Some(true)); -+ // Plain nullable column: no positive fact. -+ assert_eq!(by_name("description").field_type.as_deref(), Some("text")); -+ assert_eq!(by_name("description").not_null, None); -+ // Explicit null: true stays absent (nullable is the default). -+ assert_eq!(by_name("done_ratio").not_null, None); -+ // timestamps pair, honouring null: true. -+ assert_eq!(by_name("created_at").field_type.as_deref(), Some("datetime")); -+ assert_eq!(by_name("created_at").not_null, None); -+ // belongs_to alias. -+ assert_eq!(by_name("responsible_id").field_type.as_deref(), Some("bigint")); -+ // Polymorphic pair — the PolyRef substrate declaring itself. -+ assert_eq!(by_name("reactable_id").not_null, Some(true)); -+ assert_eq!(by_name("reactable_type").field_type.as_deref(), Some("string")); -+ // t.column explicit form. -+ assert_eq!(by_name("builtin").field_type.as_deref(), Some("boolean")); -+ assert_eq!(by_name("builtin").not_null, Some(true)); -+ } -+ -+ #[test] -+ fn id_false_suppresses_the_implicit_pk() { -+ let src = "create_table migration, id: false do |t|\n t.bigint :a_id\nend\n"; -+ let t = parse_table_source("a_b_joins", src).expect("block"); -+ assert_eq!(t.fields.len(), 1); -+ assert_eq!(t.fields[0].name, "a_id"); -+ } -+ -+ #[test] -+ fn base_helper_file_is_not_a_table() { -+ assert!(parse_table_source("base", "class Tables::Base\nend\n").is_none()); -+ } -+ -+ #[test] -+ fn inflection_covers_the_corpus_shapes() { -+ for (table, model) in [ -+ ("work_packages", "WorkPackage"), -+ ("statuses", "Status"), -+ ("categories", "Category"), -+ ("queries", "Query"), -+ ("changes", "Change"), -+ ("changesets", "Changeset"), -+ ("news", "News"), -+ ("attachments", "Attachment"), -+ ("custom_fields", "CustomField"), -+ ("issue_priorities", "IssuePriority"), -+ ] { -+ assert_eq!(model_name_for_table(table), model, "{table}"); -+ } -+ } -+ -+ /// Corpus gate (same pattern as the crate's D-AR-4 gate): only runs -+ /// with `OPENPROJECT_PATH` set. Pins the WorkPackage baseline column -+ /// set — the oracle-diff ground truth. -+ #[test] -+ fn openproject_corpus_schema_gate() { -+ let Ok(root) = std::env::var("OPENPROJECT_PATH") else { -+ eprintln!("skipping: OPENPROJECT_PATH not set"); -+ return; -+ }; -+ let (graph, report) = extract_app_with_schema(Path::new(&root), "openproject"); -+ assert_eq!(report.columns_from, "baseline-only"); -+ assert!(report.tables_seen >= 90, "expected ~99 baseline tables, saw {}", report.tables_seen); -+ assert!(report.tables_matched >= 50, "matched only {}", report.tables_matched); -+ eprintln!( -+ "D-AR-3.5 schema gate: {} tables seen, {} matched, {} unmatched, {} files skipped", -+ report.tables_seen, -+ report.tables_matched, -+ report.unmatched_tables.len(), -+ report.files_skipped.len() -+ ); -+ -+ let wp = graph -+ .models -+ .iter() -+ .find(|m| m.name == "WorkPackage") -+ .expect("WorkPackage model"); -+ let cols: Vec<&str> = wp -+ .fields -+ .iter() -+ .filter(|f| f.field_type.is_some()) -+ .map(|f| f.name.as_str()) -+ .collect(); -+ assert_eq!(cols.len(), 27, "baseline WorkPackage columns: {cols:?}"); -+ for expected in [ -+ "id", -+ "type_id", -+ "project_id", -+ "subject", -+ "description", -+ "due_date", -+ "category_id", -+ "status_id", -+ "assigned_to_id", -+ "priority_id", -+ "version_id", -+ "author_id", -+ "lock_version", -+ "done_ratio", -+ "estimated_hours", -+ "created_at", -+ "updated_at", -+ "start_date", -+ "responsible_id", -+ "derived_estimated_hours", -+ "schedule_manually", -+ "parent_id", -+ "duration", -+ "ignore_non_working_days", -+ "derived_remaining_hours", -+ "derived_done_ratio", -+ "project_phase_id", -+ ] { -+ assert!(cols.contains(&expected), "missing column {expected}"); -+ } -+ let by_name = |n: &str| wp.fields.iter().find(|f| f.name == n).unwrap(); -+ assert_eq!(by_name("subject").field_type.as_deref(), Some("string")); -+ assert_eq!(by_name("subject").not_null, Some(true)); -+ assert_eq!(by_name("done_ratio").field_type.as_deref(), Some("integer")); -+ assert_eq!(by_name("done_ratio").not_null, None, "unset ≠ 0% — the oracle-diff bug"); -+ assert_eq!(by_name("schedule_manually").not_null, Some(true)); -+ } -+} diff --git a/vendor/AdaWorldAPI-ruff/README.md b/vendor/AdaWorldAPI-ruff/README.md deleted file mode 100644 index 07b87da..0000000 --- a/vendor/AdaWorldAPI-ruff/README.md +++ /dev/null @@ -1,132 +0,0 @@ -# vendor/AdaWorldAPI-ruff/ - -**Source-mirror** of the changes that close Sprint C1 Gap 1 (seaorm→sqlx target) -in [`AdaWorldAPI/ruff`](https://github.com/AdaWorldAPI/ruff). Paths under -`crates/ruff_python_dto_check/` match the upstream layout exactly, so the -files here can be reviewed in the nexgen branch and applied to upstream ruff -without any path translation. - -The corresponding apply-bundle (`sprint-c1-gap1.patch` + -`sprint-c1-gap1-final.tar.gz`) lives in -`../../calibration/sprint-c1-gap1-ruff/`. - -## Why vendored here - -`AdaWorldAPI/ruff` is outside this session's MCP push scope. The workbench at -`/home/user/ruff-clone` can read+clone the public repo but cannot locally -commit (the env's commit-signing server only accepts local-proxy origins). -Vendoring the changed files into nexgen makes the actual code reviewable on -the `claude/beautiful-gates-dJo0u` branch; the patch in -`calibration/sprint-c1-gap1-ruff/` is the application form. - -## Contents (16 files in the patch; 14 files here + 1 diff) - -### Net-new (12 files, copied verbatim) - -| Path | Purpose | -|---|---| -| `crates/ruff_python_dto_check/src/codegen/sqlx_emit/mod.rs` | Module aggregate | -| `crates/ruff_python_dto_check/src/codegen/sqlx_emit/list_for_tenant.rs` | `emit_list_for_tenant_sqlx` | -| `crates/ruff_python_dto_check/src/codegen/sqlx_emit/detail_for_tenant.rs` | `emit_detail_for_tenant_sqlx` | -| `crates/ruff_python_dto_check/src/codegen/sqlx_emit/soft_delete.rs` | `emit_soft_delete_sqlx` | -| `crates/ruff_python_dto_check/src/codegen/sqlx_emit/toggle_bool_field.rs` | `emit_toggle_bool_field_sqlx` | -| `crates/ruff_python_dto_check/tests/sqlx_emit_test.rs` | 4 byte-exact golden-roundtrip tests | -| `crates/ruff_python_dto_check/tests/sqlx_target_spec_test.rs` | 4 spec + back-compat tests | -| `crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/list_for_tenant.rs` | Golden output (the spec) | -| `crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/detail_for_tenant.rs` | Golden output | -| `crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/soft_delete.rs` | Golden output | -| `crates/ruff_python_dto_check/tests/golden/codegen/sqlx/expected/toggle_bool_field.rs` | Golden output | -| `crates/ruff_python_dto_check/examples/openproject-axum-sqlx.toml` | Declarative spec example (23 mappings) | -| `crates/ruff_python_dto_check/SQLX-TARGET.md` | Public docs | - -### Modified (1 file vendored full + 1 file as diff) - -| Path | Form here | Why | -|---|---|---| -| `crates/ruff_python_dto_check/src/codegen/target.rs` | Full modified file (423 lines) | The spec is the source-of-truth artifact for review | -| `codegen-mod-rs.diff` | Hunk only (47 lines) | The base file is 1545 lines, ~98% unchanged — the diff is the meaningful unit | - -## The codegen/mod.rs delta (also in `codegen-mod-rs.diff`) - -```diff -@@ pub mod columns; - pub mod dto; - pub mod jinja; - pub mod pipeline; -+pub mod sqlx_emit; - pub mod target; - - use std::fmt::Write as _; - --use crate::codegen::target::{ModelMapping, TargetSpec}; -+use crate::codegen::target::{ModelMapping, Orm, TargetSpec}; -``` - -```diff -@@ /// Emit target source for a single contract against a target spec. - pub fn emit(contract: &RouteContract, spec: &TargetSpec) -> Emitted { -+ // sqlx target: route the four implemented kinds to `sqlx_emit` before -+ // the seaorm dispatch. The `can_emit` gate keeps kinds that aren't in -+ // the spec's `emit_kinds` list on the stub path (honest coverage). -+ // Kinds not implemented by sqlx_emit at all fall through — see the -+ // module docs for that footgun. -+ if spec.orm == Orm::Sqlx && spec.can_emit(contract.handler_kind) { -+ match contract.handler_kind { -+ HandlerKind::ListForTenant => { -+ return sqlx_emit::list_for_tenant::emit_list_for_tenant_sqlx(contract, spec); -+ } -+ HandlerKind::DetailForTenant => { -+ return sqlx_emit::detail_for_tenant::emit_detail_for_tenant_sqlx(contract, spec); -+ } -+ HandlerKind::SoftDelete => { -+ return sqlx_emit::soft_delete::emit_soft_delete_sqlx(contract, spec); -+ } -+ HandlerKind::ToggleBoolField => { -+ return sqlx_emit::toggle_bool_field::emit_toggle_bool_field_sqlx(contract, spec); -+ } -+ _ => {} -+ } -+ } - let recipe = KindRecipe::for_kind(contract.handler_kind, spec); - match recipe { -``` - -That's the entire change to `codegen/mod.rs`. 28 added lines, 0 removed, in a -1545-line file. - -## Verification - -From the workbench (`/home/user/ruff-clone` at upstream SHA `5179bc00`): - -``` -cargo check -p ruff_python_dto_check --tests -# Finished `dev` profile in 0.74s - -cargo test -p ruff_python_dto_check -# 17 + 6 + 28 + 6 + 1 + 1 + 4 + 4 = 67 tests, 0 failures -# - 17 unit tests (pre-existing) -# - 28 codegen tests (pre-existing — back-compat seaorm) -# - 6 config_parse tests (pre-existing) -# - 6 codegen unit tests (pre-existing) -# - 1 golden test, 1 observations test (pre-existing) -# - 4 sqlx_emit_test ← NEW (Sprint C1 Gap 1) -# - 4 sqlx_target_spec_test ← NEW (Sprint C1 Gap 1) -``` - -## How to apply this to upstream ruff - -```bash -git clone https://github.com/AdaWorldAPI/ruff.git -cd ruff -git checkout main # @ 5179bc00 (or newer; the patch is forward-compatible) -git checkout -b claude/openproject-sqlx-emitter -git apply ../openproject-nexgen-rs/calibration/sprint-c1-gap1-ruff/sprint-c1-gap1.patch -cargo test -p ruff_python_dto_check # expect 67 passed -git add -A -git commit -m "feat(codegen): add sqlx (axum) target for openproject port" -git push -u origin claude/openproject-sqlx-emitter -# Open PR on AdaWorldAPI/ruff -``` - -The 16 files in the patch are exactly the 14 files vendored here + -codegen/mod.rs (whose hunk is shown inline above and in `codegen-mod-rs.diff`). diff --git a/vendor/AdaWorldAPI-ruff/codegen-mod-rs.diff b/vendor/AdaWorldAPI-ruff/codegen-mod-rs.diff deleted file mode 100644 index de6aa71..0000000 --- a/vendor/AdaWorldAPI-ruff/codegen-mod-rs.diff +++ /dev/null @@ -1,53 +0,0 @@ -diff --git a/crates/ruff_python_dto_check/src/codegen/mod.rs b/crates/ruff_python_dto_check/src/codegen/mod.rs -index 54cd4b4..2886281 100644 ---- a/crates/ruff_python_dto_check/src/codegen/mod.rs -+++ b/crates/ruff_python_dto_check/src/codegen/mod.rs -@@ -15,11 +15,12 @@ pub mod columns; - pub mod dto; - pub mod jinja; - pub mod pipeline; -+pub mod sqlx_emit; - pub mod target; - - use std::fmt::Write as _; - --use crate::codegen::target::{ModelMapping, TargetSpec}; -+use crate::codegen::target::{ModelMapping, Orm, TargetSpec}; - use crate::contract::{HandlerKind, RouteContract}; - use crate::extractors::body::OutputKind; - -@@ -102,6 +103,34 @@ pub struct Emitted { - - /// Emit target source for a single contract against a target spec. - pub fn emit(contract: &RouteContract, spec: &TargetSpec) -> Emitted { -+ // sqlx target: route the four implemented kinds to `sqlx_emit` before -+ // the seaorm dispatch. The `can_emit` gate keeps kinds that aren't in -+ // the spec's `emit_kinds` list on the stub path (honest coverage). -+ // Kinds not implemented by sqlx_emit at all fall through — see the -+ // module docs for that footgun. -+ if spec.orm == Orm::Sqlx && spec.can_emit(contract.handler_kind) { -+ match contract.handler_kind { -+ HandlerKind::ListForTenant => { -+ return sqlx_emit::list_for_tenant::emit_list_for_tenant_sqlx(contract, spec); -+ } -+ HandlerKind::DetailForTenant => { -+ return sqlx_emit::detail_for_tenant::emit_detail_for_tenant_sqlx(contract, spec); -+ } -+ HandlerKind::SoftDelete => { -+ return sqlx_emit::soft_delete::emit_soft_delete_sqlx(contract, spec); -+ } -+ HandlerKind::ToggleBoolField => { -+ return sqlx_emit::toggle_bool_field::emit_toggle_bool_field_sqlx(contract, spec); -+ } -+ HandlerKind::AjaxJson => { -+ return sqlx_emit::ajax_json::emit_ajax_json_sqlx(contract, spec); -+ } -+ HandlerKind::CsrfFormPostEngineCall => { -+ return sqlx_emit::csrf_form_post::emit_csrf_form_post_sqlx(contract, spec); -+ } -+ _ => {} -+ } -+ } - let recipe = KindRecipe::for_kind(contract.handler_kind, spec); - match recipe { - KindRecipe::ListForTenant => emit_list_for_tenant(contract, spec), diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/Cargo.toml b/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/Cargo.toml deleted file mode 100644 index 0195071..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "ruff_ruby_spo" -version = "0.1.0" -publish = false -edition = "2024" -rust-version = "1.86" -description = "SCAFFOLD: Ruby/Rails (OpenProject) frontend for ruff_spo_triplet. Walks an app/models tree and fills a language-agnostic ModelGraph, which expands to the same SPO triples as the Python/Odoo frontend. Extraction points are todo!() stubs documented with the exact Rails construct to read; the target triple shape is already locked by a passing test." -license = "MIT" - -[lib] -name = "ruff_ruby_spo" - -[dependencies] -ruff_spo_triplet = { workspace = true } - -# Pure-Rust Ruby parser (typed AST, no Ruby runtime). Operator-locked -# direction for D-AR-3 (the real extractor): the AST gives us -# class-body node access without the brittleness of line/regex scanning, -# so the 67-name DSL routing dispatches on `Node` variants instead of -# string prefixes. -lib-ruby-parser = { version = "4.0", default-features = false } - -[lints] -workspace = true diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/RUBY-FRONTEND.md b/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/RUBY-FRONTEND.md deleted file mode 100644 index ab571f1..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/RUBY-FRONTEND.md +++ /dev/null @@ -1,161 +0,0 @@ -# ruff_ruby_spo — finished Ruby/Rails frontend - -> Sprint C4. The OpenProject (Ruby/Rails) source-language frontend onto the -> shared SPO triplet core. See `crates/ruff_spo_triplet/SPO_TRIPLET_EXTRACTION.md` -> §5–§6 for the methodology this implements; this doc records what the CODE does. - -## What it does - -`extract(source_tree)` walks a Rails tree — `app/models/*.rb` for the class -definitions and `db/schema.rb` for the per-table column baseline — and fills a -language-agnostic `ModelGraph` (`ruff_spo_triplet::ir`). That graph `expand()`s -to the exact same closed SPO triple vocabulary as the Python/Odoo frontend, so -every downstream consumer (`lance_graph` SPO loader, `action_emitter`, -`link_chain`) works on the OpenProject graph with zero changes. The reuse seam -is the IR; everything below it is shared. - -There is **no external Ruby parser**. The scaffold's original intent (lib.rs -header, `Cargo.toml` "zero external parser deps by design") was to pin the -target triple shape with a passing test before wiring a parser. C4 keeps that -decision: instead of pulling in `lib-ruby-parser` or `tree-sitter-ruby`, it -ships a focused, dependency-free **line/block scanner** (`scan`) for the regular -subset of ActiveRecord model files, and builds the three extractors on top of -it. `Cargo.toml` still carries only the `ruff_spo_triplet` dependency. - -The three extractors are file-disjoint and share the `scan` primitives so they -agree on how a class body is tokenised rather than each re-inventing string -slicing. `extract()` joins them: parse classes, then per class fill -`model.fields` and `model.functions`, push to the graph. - -## Module layout - -| file | role | -| --- | --- | -| `src/lib.rs` | `extract()` entry, `RubyClass` raw record (note the C4 `columns` field), `NAMESPACE = "openproject"`, module wiring. | -| `src/scan.rs` | Shared dependency-free primitives: `strip_comment`, `macro_symbols`, `def_blocks` (keyword-depth method splitter), `ivar_assignments`. Unit-tested. | -| `src/parse.rs` | `parse_models`: walk `app/models`, build a `RubyClass` per `class X < …Record`, seed `associations` + join `db/schema.rb` `columns`. | -| `src/fields.rs` | `extract_fields`: schema columns → baseline `Field`s; memoized `@ivar` assignments → derived `Field`s (`emitted_by` + association-chain `depends_on`). | -| `src/functions.rs` | `extract_functions`: `def` bodies → `Function{reads,raises,traverses}` + a synthetic validate fn for declarative `validates`. (impl landing in this same sprint.) | - -## The Rails -> IR mapping (as implemented) - -Mirrors SPO_TRIPLET_EXTRACTION.md §5 step-2, stated as what the scanner does. -WorkPackage (fixture) is the worked example. - -| IR target | What the code reads | -| --- | --- | -| `Model::name` | `parse::class_name` — `class WorkPackage < ` → `WorkPackage`. Any superclass accepted; no dot normalisation (Rails names have none). | -| `Field` (baseline) | `parse::parse_schema` columns for the class's table (`WorkPackage`→`work_packages` via `to_snake`+`pluralize`), one bare `Field` each. | -| `Field` (derived) | `fields::extract_fields` — `scan::ivar_assignments` inside a `def` (`@total_hours ||= …`) → a `Field` `emitted_by` that method. | -| `Field::depends_on` | `fields::association_chains` — `.` chains the method reads, at identifier boundaries (`time_entries.hours`). Verbatim, source-faithful. | -| associations | `parse` collects `belongs_to`/`has_many`/`has_one`/`has_and_belongs_to_many` leading symbols → the valid relation-name set (`scan::macro_symbols`). | -| `Function::name` | `scan::def_blocks` top-level `def` names (strips `self.`, arg lists). | -| `Function::reads` | bare attribute reads in the body (e.g. `status`). | -| `Function::raises` | `raise ` / `errors.add` → `exc:`; `validates`/`validate` → synthetic guard raising `exc:ActiveRecord::RecordInvalid`. | -| `Function::traverses` | body calls to a declared association name → that relation (`time_entries`). | - -## Worked example - -Fixture `app/models/work_package.rb`: - -```ruby -class WorkPackage < ApplicationRecord - belongs_to :project - has_many :time_entries - - validates :subject, presence: true - - def compute_total_hours - raise ActiveRecord::RecordInvalid unless status - @total_hours ||= time_entries.hours - end -end -``` - -With `db/schema.rb` `work_packages` columns (`subject`, `description`, -`status_id`, `status`, `created_at`, `updated_at`) this extracts to: - -- **Model** `WorkPackage` -- **Fields**: the 6 schema columns (bare) + derived `total_hours` - `{ emitted_by: compute_total_hours, depends_on: [time_entries.hours] }`. -- **Functions**: `compute_total_hours { reads: [status], raises: - [ActiveRecord::RecordInvalid], traverses: [time_entries] }`, plus a synthetic - validate fn raising `ActiveRecord::RecordInvalid` for `validates :subject`. - -`expand()` projects that to SPO triples (exact shapes from -`tests/ruby_extract_test.rs`): - -``` -openproject:WorkPackage rdf:type ogit:ObjectType -openproject:WorkPackage.total_hours emitted_by openproject:WorkPackage.compute_total_hours -openproject:WorkPackage.total_hours depends_on openproject:WorkPackage.time_entries.hours -openproject:WorkPackage.compute_total_hours raises exc:ActiveRecord::RecordInvalid -openproject:WorkPackage.compute_total_hours traverses_relation openproject:WorkPackage.time_entries -``` - -A second class `TimeEntry` (columns only) gives -`openproject:TimeEntry rdf:type ogit:ObjectType`. The `time_entries.hours` -dependency path is emitted verbatim under the model IRI; the downstream -`link_chain` splitter does the per-hop decomposition, not this crate. - -## Scope + limits (honest) - -This is a line scanner for the **regular** ActiveRecord subset, not a Ruby -parser. Do not read it as one. - -Handles well: standard `class X < ApplicationRecord` (any superclass); -single-symbol association macros with trailing options hashes -(`has_many :time_entries, dependent: :destroy`); `def`/`end` blocks with nested -`if`/`unless`/`case`/`do` (keyword-depth counting in `scan::def_blocks`); -memoized `@x ||=` / `@x =` (but not `==`); comment- and string-literal-aware -stripping; declarative `validates`/`validate` guards. - -Does **not** yet handle (EXTRACTOR-GAPs — under-extraction, not silently wrong -output): metaprogramming (`define_method`, dynamic `send`); STI subtype nuance -(the subclass is captured, hierarchy semantics are not); method chains broken -across multiple physical lines; mixed-in concerns/modules (only the file's own -class body is scanned); heredocs; `scope :x, ->(…) { … }` lambda bodies; -namespaced/`::`-qualified association targets. - -Truth tiers (NARS, per SPO_TRIPLET_EXTRACTION.md §2): declared associations and -`validates`/`raise` guards land as **Authoritative**; inferred body reads and -relation traversals land as **Inferred**, so a strict downstream query can drop -the heuristic edges and keep only declared facts. The gaps above cause missing -edges, never a wrong tier. - -## How to run / verify - -```sh -cargo test -p ruff_ruby_spo -``` - -Covers three layers: - -- `src/scan.rs` unit tests — `strip_comment` string-awareness, `macro_symbols` - leading-symbol-only, `def_blocks` nested-block/modifier handling, - `ivar_assignments` memoization. -- `src/lib.rs` `locked_shape_expands_to_expected_triples` — the hand-built - target `ModelGraph` (what "done" looks like), provenance-independent. -- `tests/ruby_extract_test.rs` — end-to-end: real `extract()` over - `tests/fixtures/openproject/`, asserting the expanded triples, the synthetic - validation raise, and full `openproject:`/`exc:` namespacing. - -Point it at a real checkout: - -```rust -use std::path::Path; -let graph = ruff_ruby_spo::extract(Path::new("/path/to/openproject")); -``` - -## Next (Sprint C5+) - -- **Wire output to ndjson + the loader.** `expand(&graph)` then - `ruff_spo_triplet::to_ndjson(&triples)` → `openproject.spo.ndjson`, loaded by - the downstream `lance_graph` SPO loader per SPO_TRIPLET_EXTRACTION.md §4–§5. - Downstream needs zero changes; the format is identical to Odoo's. -- **Harden the scanner** against the gap list (heredocs, multi-line chains, - concerns, `scope` lambdas, `define_method`) — or graduate to - `lib-ruby-parser` if the gaps stop being acceptable as under-extraction. -- **Add fixtures**: callbacks (`before_save`/`after_create`), `attribute`/ - `store_accessor` derived fields, STI hierarchies — and a downstream - `ActionSpec`-shape parity test to close the loop (§7). diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/functions.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/functions.rs deleted file mode 100644 index 9e19785..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/functions.rs +++ /dev/null @@ -1,1014 +0,0 @@ -//! Class-body method-def walker — extracts [`ruff_spo_triplet::Function`] -//! records from `def name … end` blocks (D-AR-3.5). -//! -//! # What it captures -//! -//! - **`Function::name`** — the method name from `Node::Def` (instance -//! methods only; `Node::Defs` for `def self.foo` is class-method -//! territory, treated separately). -//! - **`Function::raises`** — every `raise X[.new(…)]` statement -//! reachable from the body. The exception type name is the constant -//! passed to `raise`; falls back to a marker for `raise ` forms -//! that aren't a static constant. -//! - **`Function::traverses`** — every `.each` / `for r in -//! ` / association walk on `self.` whose name matches -//! one of the class's declared associations. The walker takes the -//! `known_relations` slice so it can filter (no relation declared → -//! no traversal recorded; conservative for D-AR-3.5). -//! - **`Function::reads`** — `self.` reads and bare attribute -//! reads (no scope analysis — every `Send { recv: self, method: foo }` -//! that is not a write or a mutator counts as a read of `foo`). -//! - **`Function::writes`** — `self. = …` setter calls. The `=` -//! suffix on the method name marks the assignment; the field is the name -//! without the `=`. Plain instance-var assignment (`@x = …`) is NOT a -//! write — it is local memoization, not an AR attribute. -//! - **`Function::calls`** — `ActiveRecord` lifecycle-mutator dispatches -//! (`save` / `update` / `destroy` / …) on any receiver, recorded as -//! `"."`. Only the closed mutator set is captured — the -//! body-pass triage (E-ACCIDENTAL-IMPERATIVE / OGAR F17) needs "does this -//! method call a writer", not every call. -//! -//! Together, `writes` + `calls` are the **command-shape** facts that let the -//! triage split a method into query (read-only) vs command (mutates state). -//! -//! # What it doesn't capture (deferred) -//! -//! - Class methods (`def self.foo`, `class << self` blocks). -//! - `errors.add(...)` → `raises ActiveRecord::RecordInvalid` mapping. -//! - Block-form callbacks (`before_save do |r| … end`) — the block -//! body's def-less statements aren't reachable here. -//! - Receiver-walks that span multiple hops (`self.project.members`). -//! - Op-assign writes (`self.x += 1`, `self.x ||= y`) — only the plain -//! `self.x = …` setter form is recorded as a write today. -//! -//! These all land in follow-up D-AR-3.6 (method bodies are deep; the -//! 80/20 here is method NAMES + leaf `raise` + association walks). - -use lib_ruby_parser::Node; -use ruff_spo_triplet::Function; - -use crate::Declaration; - -/// Walk a class body and produce one [`Function`] per `def`. The -/// `declarations` slice lets the body walker filter `traverses_relation` -/// candidates to known association names (per the Inferred-tier -/// I-RAILS-RELATION-WALK convention). -#[must_use] -pub(crate) fn extract_functions_from_body( - body: Option<&Node>, - declarations: &[Declaration], -) -> Vec { - let Some(body) = body else { - return Vec::new(); - }; - let known_relations = collect_known_relations(declarations); - let mut out = Vec::new(); - // Visibility-at-def, parallel to `out` (true = public where the def appeared). - let mut vis: Vec = Vec::new(); - // Symbol-form statements in source order: `private :a` / `public :a`. The - // LAST statement about a name wins (`private :x` then `public :x` ⇒ public). - let mut sym_overrides: Vec<(String, bool)> = Vec::new(); - // Default visibility, threaded so it persists across transparent `begin/end` - // wrappers (a bare `private` before a nested begin still governs later defs). - let mut default_public = true; - walk_class_body_for_defs( - body, - &known_relations, - &mut out, - &mut vis, - &mut sym_overrides, - &mut default_public, - ); - // Ruby visibility filtering: `private`/`protected` helpers (`set_project`, - // `authorize`, …) are NOT routable actions and must not be harvested. Final - // visibility = visibility-at-def, then each symbol-form override applied in - // order (so `public :x` re-publishes a method an earlier `private :x` hid). - for (name, is_public) in &sym_overrides { - for (i, f) in out.iter().enumerate() { - if &f.name == name { - vis[i] = *is_public; - } - } - } - out.into_iter() - .zip(vis) - .filter_map(|(f, public)| public.then_some(f)) - .collect() -} - -/// Pre-compute the set of relation names declared on the class so -/// `traverses_relation` extraction can filter body-walked sends. -fn collect_known_relations(decls: &[Declaration]) -> Vec { - let mut names = Vec::new(); - for d in decls { - if let Declaration::Association(a) = d { - names.push(a.name.clone()); - } - } - names -} - -/// Recurse into Begin / Module / Class wrappers AND `class_methods do` -/// blocks; for each **public** `Node::Def` encountered, extract one Function. -/// -/// Ruby method visibility is tracked so non-public helpers are not harvested as -/// actions: a bare `private` / `protected` statement flips subsequent defs to -/// non-public (a bare `public` flips back); `private def foo …` marks just that -/// inline def; `private :foo` records an ordered `sym_overrides` entry the caller -/// applies by name (last wins, so a later `public :foo` re-publishes it). -fn walk_class_body_for_defs( - node: &Node, - known_relations: &[String], - out: &mut Vec, - vis: &mut Vec, - sym_overrides: &mut Vec<(String, bool)>, - default_public: &mut bool, -) { - match node { - // Both an implicit statement sequence (`Node::Begin`) and an explicit - // `begin … end` (`Node::KwBegin`) are transparent to visibility: - // `default_public` flows in and out unchanged, so a bare `private` inside - // a nested begin still governs later class-body siblings. - Node::Begin(b) => walk_body_stmts( - &b.statements, - known_relations, - out, - vis, - sym_overrides, - default_public, - ), - Node::KwBegin(b) => walk_body_stmts( - &b.statements, - known_relations, - out, - vis, - sym_overrides, - default_public, - ), - Node::Def(d) => { - let mut func = Function { - name: d.name.clone(), - reads: Vec::new(), - raises: Vec::new(), - traverses: Vec::new(), - writes: Vec::new(), - calls: Vec::new(), - }; - if let Some(fn_body) = d.body.as_deref() { - walk_method_body(fn_body, known_relations, &mut func); - } - dedup_in_place(&mut func.reads); - dedup_in_place(&mut func.raises); - dedup_in_place(&mut func.traverses); - dedup_in_place(&mut func.writes); - dedup_in_place(&mut func.calls); - out.push(func); - // Visibility-at-def: the default in force where this `def` appeared. - vis.push(*default_public); - } - Node::Block(blk) => { - // `class_methods do … end` / `included do … end` blocks wrap `def` - // nodes. The block INHERITS the enclosing default visibility, but its - // own bare markers are scoped to the block — save/restore so a - // `private` inside the block does not leak out to later class-body - // siblings (D-AR-3.6 will split class-method discovery off). - if let Some(body) = blk.body.as_deref() { - let saved = *default_public; - walk_class_body_for_defs( - body, - known_relations, - out, - vis, - sym_overrides, - default_public, - ); - *default_public = saved; - } - } - _ => {} - } -} - -/// Walk a linear statement sequence (a class body or a transparent `begin/end`), -/// tracking `default_public` visibility across siblings and emitting one -/// [`Function`] per public `def`. -fn walk_body_stmts( - stmts: &[Node], - known_relations: &[String], - out: &mut Vec, - vis: &mut Vec, - sym_overrides: &mut Vec<(String, bool)>, - default_public: &mut bool, -) { - for stmt in stmts { - if let Node::Send(s) = stmt { - if s.recv.is_none() - && matches!(s.method_name.as_str(), "private" | "protected" | "public") - { - if s.args.is_empty() { - // Bare marker: flips the default for subsequent defs. - *default_public = s.method_name == "public"; - continue; - } - // Argument forms. - let is_public = s.method_name == "public"; - for arg in &s.args { - match arg { - // `private :a` / `public :a` — record an ordered - // override applied by name (last wins). - Node::Sym(sym) => { - sym_overrides.push((sym.name.to_string_lossy(), is_public)); - } - // `private def foo …` / `public def foo …` — emit the - // inline def with THIS explicit visibility. - Node::Def(_) => { - let before = out.len(); - walk_class_body_for_defs( - arg, - known_relations, - out, - vis, - sym_overrides, - default_public, - ); - for v in &mut vis[before..] { - *v = is_public; - } - } - _ => {} - } - } - continue; - } - } - walk_class_body_for_defs( - stmt, - known_relations, - out, - vis, - sym_overrides, - default_public, - ); - } -} - -/// Walk one method body, populating `func.reads` / `raises` / `traverses`. -fn walk_method_body(node: &Node, known_relations: &[String], func: &mut Function) { - match node { - Node::Begin(b) => { - for stmt in &b.statements { - walk_method_body(stmt, known_relations, func); - } - } - // `raise X` / `raise X.new(...)` / `raise X, ...`. - Node::Send(s) if s.method_name == "raise" && s.recv.is_none() => { - if let Some(arg) = s.args.first() { - if let Some(exc_name) = exception_type_name(arg) { - func.raises.push(exc_name); - } - } - } - // `self.` — write (`self.x = …`), mutator call (`self.save`), - // or plain attribute read (`self.x`), in that priority order. - Node::Send(s) if matches!(s.recv.as_deref(), Some(Node::Self_(_))) => { - let method = s.method_name.as_str(); - if let Some(field) = method.strip_suffix('=') - && is_attr_ident(field) - { - // `self. = …` — the setter call: a write of ``. - // The `is_attr_ident` guard excludes comparison operators - // (`==`, `<=`, `>=`, `===`) and `[]=`, which also end in `=` - // but are not setters — without it, `self == other` would - // record a bogus write of a field named `=`. - func.writes.push(field.to_string()); - } else if is_ar_mutator(method) { - // `self.save` / `self.update(...)` — lifecycle mutator on self. - func.calls.push(format!("self.{method}")); - } else if is_attr_ident(method) { - // `self.` — a plain attribute read (operator self-sends - // such as `self == other` are neither a read nor a write). - func.reads.push(s.method_name.clone()); - } - // Recurse into args (the RHS of a write, or call/read args, may - // themselves contain a raise/read/write/call). - for arg in &s.args { - walk_method_body(arg, known_relations, func); - } - } - // `.each` / `.` — association walks, plus any - // `ActiveRecord` lifecycle mutator dispatched on a non-self receiver - // (`order.update`, `User.create`, bare `save`). - Node::Send(s) => { - if is_ar_mutator(&s.method_name) { - func.calls.push(format!( - "{}.{}", - receiver_label(s.recv.as_deref()), - s.method_name - )); - } - if let Some(rel) = traversed_relation(s, known_relations) { - func.traverses.push(rel); - } - if let Some(recv) = s.recv.as_deref() { - walk_method_body(recv, known_relations, func); - } - for arg in &s.args { - walk_method_body(arg, known_relations, func); - } - } - // `for r in ` — classic for-loop traversal. - Node::For(f) => { - if let Some(rel) = node_relation_name(&f.iteratee, known_relations) { - func.traverses.push(rel); - } - if let Some(body) = f.body.as_deref() { - walk_method_body(body, known_relations, func); - } - } - // Walk into structural wrappers without changing func state. - Node::Block(blk) => { - if let Some(body) = blk.body.as_deref() { - walk_method_body(body, known_relations, func); - } - walk_method_body(&blk.call, known_relations, func); - } - Node::If(i) => { - if let Some(b) = i.if_true.as_deref() { - walk_method_body(b, known_relations, func); - } - if let Some(b) = i.if_false.as_deref() { - walk_method_body(b, known_relations, func); - } - walk_method_body(&i.cond, known_relations, func); - } - Node::Case(c) => { - for arm in &c.when_bodies { - walk_method_body(arm, known_relations, func); - } - if let Some(b) = c.else_body.as_deref() { - walk_method_body(b, known_relations, func); - } - } - Node::Ensure(e) => { - if let Some(b) = e.body.as_deref() { - walk_method_body(b, known_relations, func); - } - if let Some(b) = e.ensure.as_deref() { - walk_method_body(b, known_relations, func); - } - } - Node::Rescue(r) => { - if let Some(b) = r.body.as_deref() { - walk_method_body(b, known_relations, func); - } - for arm in &r.rescue_bodies { - walk_method_body(arm, known_relations, func); - } - } - Node::RescueBody(r) => { - if let Some(b) = r.body.as_deref() { - walk_method_body(b, known_relations, func); - } - if let Some(e) = r.exc_list.as_deref() { - walk_method_body(e, known_relations, func); - } - } - Node::Return(r) => { - for arg in &r.args { - walk_method_body(arg, known_relations, func); - } - } - Node::Lvasgn(a) => { - if let Some(v) = a.value.as_deref() { - walk_method_body(v, known_relations, func); - } - } - Node::Ivasgn(a) => { - if let Some(v) = a.value.as_deref() { - walk_method_body(v, known_relations, func); - } - } - _ => {} - } -} - -/// The closed set of `ActiveRecord` lifecycle mutators. A call to one of -/// these marks a method as a *command* (it writes persistent state) rather -/// than a *query*. The body-pass triage (E-ACCIDENTAL-IMPERATIVE / OGAR F17) -/// groups methods by "calls a writer" — this IS that set. Not every call is -/// captured into `Function::calls`; only a dispatch of one of these verbs. -const AR_MUTATORS: &[&str] = &[ - "create", - "create!", - "update", - "update!", - "update_all", - "update_attribute", - "update_column", - "update_columns", - "destroy", - "destroy!", - "destroy_all", - "delete", - "delete_all", - "save", - "save!", - "insert", - "insert_all", - "upsert", - "upsert_all", - "touch", - "increment!", - "decrement!", - "toggle!", - "write_attribute", -]; - -/// Is `method` one of the [`AR_MUTATORS`]? -fn is_ar_mutator(method: &str) -> bool { - AR_MUTATORS.contains(&method) -} - -/// Is `name` a valid Ruby attribute identifier — `[A-Za-z_][A-Za-z0-9_]*`? -/// Distinguishes attribute reads/setters (`name`, `name=`) from operator -/// methods (`==`, `<=`, `[]=`, `+`, …), so an operator self-send never -/// becomes a `reads`/`writes` entry. A setter is recognised by stripping the -/// trailing `=` and checking the base with this — `==` strips to `=` (not an -/// ident → not a write), `state=` strips to `state` (ident → a write). -fn is_attr_ident(name: &str) -> bool { - let mut chars = name.chars(); - matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') - && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') -} - -/// Best-effort label for a call receiver, used for the `calls` capture -/// (`"."`). A bare call (`None`) and an explicit `self` -/// both render as `"self"`. A relation/local receiver renders as its name; a -/// constant as its dotted path; an unresolvable receiver as `""`. -fn receiver_label(recv: Option<&Node>) -> String { - match recv { - None | Some(Node::Self_(_)) => "self".to_string(), - Some(node @ Node::Const(_)) => { - const_to_dotted(node).unwrap_or_else(|| "".to_string()) - } - Some(Node::Lvar(l)) => l.name.clone(), - Some(Node::Ivar(i)) => i.name.clone(), - // `order.update` / `self.order.update` — the immediate receiver is a - // bare or self-rooted send naming the relation/attribute. - Some(Node::Send(inner)) - if inner.recv.is_none() - || matches!(inner.recv.as_deref(), Some(Node::Self_(_))) => - { - inner.method_name.clone() - } - _ => "".to_string(), - } -} - -/// Extract the exception type-name from a `raise ` argument. -/// -/// - `raise UserError` → `"UserError"` (`Node::Const`). -/// - `raise UserError.new("msg")` → `"UserError"` (`Node::Send` with recv=Const). -/// - `raise UserError, "msg"` → handled by caller via `args.first()`. -/// - `raise foo` (variable) → `None` (can't statically resolve). -fn exception_type_name(arg: &Node) -> Option { - match arg { - Node::Const(_) => const_to_dotted(arg), - Node::Send(s) if s.method_name == "new" => { - s.recv.as_deref().and_then(const_to_dotted) - } - _ => None, - } -} - -/// Render a constant-chain node to a dotted string. (Re-implemented -/// here because `parse.rs::const_to_string` is module-local; both -/// functions agree on the format.) -fn const_to_dotted(node: &Node) -> Option { - let Node::Const(c) = node else { return None }; - let suffix = c.name.clone(); - match c.scope.as_deref() { - Some(Node::Cbase(_)) => Some(format!("::{suffix}")), - Some(inner) => { - const_to_dotted(inner).map(|p| format!("{p}::{suffix}")) - } - None => Some(suffix), - } -} - -/// If `s` is a method call whose immediate receiver is a known relation -/// name, return the relation. The receiver can be: -/// -/// - `self.` — `s.recv == Self_`, but then we'd be reading -/// `` on self, not traversing — handled by the `reads` arm. -/// - `` bare (the recv is `Node::Send { method_name: "rel", recv: None }` -/// OR `Node::Lvar("rel")`) — that's the traversal entry. -fn traversed_relation(s: &lib_ruby_parser::nodes::Send, known: &[String]) -> Option { - let recv = s.recv.as_deref()?; - // `.each` — recv is a bare send with method == rel name and no - // further receiver. - if let Node::Send(inner) = recv { - if inner.recv.is_none() && known.iter().any(|r| r == &inner.method_name) { - return Some(inner.method_name.clone()); - } - } - // `self..each` — recv is `self.`, i.e. a Send with recv=Self. - if let Node::Send(inner) = recv { - if matches!(inner.recv.as_deref(), Some(Node::Self_(_))) - && known.iter().any(|r| r == &inner.method_name) - { - return Some(inner.method_name.clone()); - } - } - None -} - -/// `for r in ` — does `` name a known relation? -fn node_relation_name(node: &Node, known: &[String]) -> Option { - match node { - Node::Send(s) if s.recv.is_none() && known.iter().any(|r| r == &s.method_name) => { - Some(s.method_name.clone()) - } - Node::Send(s) - if matches!(s.recv.as_deref(), Some(Node::Self_(_))) - && known.iter().any(|r| r == &s.method_name) => - { - Some(s.method_name.clone()) - } - _ => None, - } -} - -fn dedup_in_place(v: &mut Vec) { - v.sort(); - v.dedup(); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::extract_from_source; - use lib_ruby_parser::{Parser, ParserOptions}; - - fn class_functions(src: &str) -> Vec { - let classes = extract_from_source(src); - assert_eq!(classes.len(), 1, "expected exactly one class"); - // The test entry uses the public extract path which still has - // empty functions — call extract_functions_from_body directly - // on the AST. Re-parse for the test fixture. - let parser = Parser::new(src.as_bytes().to_vec(), ParserOptions::default()); - let ast = parser.do_parse().ast.expect("parse"); - let class = find_first_class(&ast).expect("class"); - extract_functions_from_body(class.body.as_deref(), &classes[0].declarations) - } - - fn find_first_class(node: &Node) -> Option<&lib_ruby_parser::nodes::Class> { - match node { - Node::Class(c) => Some(c), - Node::Module(m) => m.body.as_deref().and_then(find_first_class), - Node::Begin(b) => b.statements.iter().find_map(find_first_class), - _ => None, - } - } - - #[test] - fn bare_private_marker_hides_subsequent_defs() { - // The Rails controller pattern: public actions, then a bare `private`, - // then non-routable helpers (`set_project`, `authorize`). Only the - // public actions must be harvested (codex #42). - let funcs = class_functions( - r#" -class NodesController - def show - end - def create - end - - private - - def set_project - end - def authorize - end -end -"#, - ); - let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect(); - assert_eq!(names, ["show", "create"]); - } - - #[test] - fn public_marker_flips_visibility_back() { - let funcs = class_functions( - r#" -class M - private - def helper - end - public - def action - end -end -"#, - ); - let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect(); - assert_eq!(names, ["action"]); - } - - #[test] - fn private_symbol_form_hides_named_method() { - // `private :set_project` after the def — retroactive by name. - let funcs = class_functions( - r#" -class M - def show - end - def set_project - end - private :set_project -end -"#, - ); - let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect(); - assert_eq!(names, ["show"]); - } - - #[test] - fn private_inline_def_form_is_hidden() { - let funcs = class_functions( - r#" -class M - def show - end - private def set_project - end -end -"#, - ); - let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect(); - assert_eq!(names, ["show"]); - } - - #[test] - fn visibility_is_transparent_across_begin_end() { - // A bare `private` inside a nested `begin/end` still governs later - // class-body siblings (begin/end is transparent in Ruby) — the def after - // it must NOT be harvested (Bugbot: visibility reset per Begin). - let funcs = class_functions( - r#" -class M - def show - end - begin - private - end - def helper - end -end -"#, - ); - let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect(); - assert_eq!(names, ["show"]); - } - - #[test] - fn public_symbol_republishes_a_privatised_method() { - // `private :show` then `public :show` — Ruby's final visibility is - // public, so `show` must be harvested (codex P2 / Bugbot: append-only - // non_public dropped it). - let funcs = class_functions( - r#" -class M - def show - end - private :show - public :show -end -"#, - ); - let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect(); - assert_eq!(names, ["show"]); - } - - #[test] - fn def_emits_function_name() { - let funcs = class_functions( - r#" -class M - def compute_total - end - def status - end -end -"#, - ); - let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect(); - assert_eq!(names, ["compute_total", "status"]); - } - - #[test] - fn raise_const_captures_exception_type() { - let funcs = class_functions( - r#" -class M - def must_be_valid - raise ::ActiveRecord::RecordInvalid - end -end -"#, - ); - assert_eq!(funcs[0].raises, vec!["::ActiveRecord::RecordInvalid"]); - } - - #[test] - fn raise_new_captures_exception_type() { - let funcs = class_functions( - r#" -class M - def fail! - raise UserError.new("oops") - end -end -"#, - ); - assert_eq!(funcs[0].raises, vec!["UserError"]); - } - - #[test] - fn raise_variable_skipped() { - let funcs = class_functions( - r#" -class M - def relay(err) - raise err - end -end -"#, - ); - assert!(funcs[0].raises.is_empty(), "variable raise must not be captured"); - } - - #[test] - fn self_dot_attribute_emits_read() { - let funcs = class_functions( - r#" -class M - def fmt - self.subject - end -end -"#, - ); - assert_eq!(funcs[0].reads, vec!["subject"]); - } - - #[test] - fn association_walk_emits_traversal() { - let funcs = class_functions( - r#" -class M - belongs_to :project - has_many :time_entries - - def total_hours - time_entries.each { |te| te.hours } - end - - def project_name - self.project.name - end -end -"#, - ); - let total_hours = funcs.iter().find(|f| f.name == "total_hours").unwrap(); - assert!( - total_hours.traverses.contains(&"time_entries".to_string()), - "expected time_entries traversal; got {:?}", - total_hours.traverses, - ); - let proj_name = funcs.iter().find(|f| f.name == "project_name").unwrap(); - assert!( - proj_name.traverses.contains(&"project".to_string()), - "expected project traversal; got {:?}", - proj_name.traverses, - ); - } - - #[test] - fn for_loop_emits_traversal() { - let funcs = class_functions( - r#" -class M - has_many :time_entries - - def report - for t in time_entries - t.hours - end - end -end -"#, - ); - assert!( - funcs[0].traverses.contains(&"time_entries".to_string()), - "for-loop must extract traversal; got {:?}", - funcs[0].traverses, - ); - } - - #[test] - fn unrelated_send_does_not_emit_traversal() { - let funcs = class_functions( - r#" -class M - def calc - something_unknown.each { |x| x } - end -end -"#, - ); - assert!( - funcs[0].traverses.is_empty(), - "no traversal expected for unknown method; got {:?}", - funcs[0].traverses, - ); - } - - #[test] - fn raises_in_rescue_arm_captured() { - let funcs = class_functions( - r#" -class M - def safe - do_work - rescue StandardError - raise UserError - end -end -"#, - ); - assert!( - funcs[0].raises.contains(&"UserError".to_string()), - "rescue-arm raise must be captured; got {:?}", - funcs[0].raises, - ); - } - - #[test] - fn self_assignment_emits_write_not_read() { - let funcs = class_functions( - r#" -class M - def post - self.state = "posted" - end -end -"#, - ); - assert_eq!(funcs[0].writes, vec!["state"]); - // The `state=` setter must NOT leak into `reads` as `"state="`. - assert!( - funcs[0].reads.is_empty(), - "a write must not be recorded as a read; got reads {:?}", - funcs[0].reads, - ); - } - - #[test] - fn write_and_read_coexist() { - let funcs = class_functions( - r#" -class M - def recompute - self.total = self.subtotal - end -end -"#, - ); - assert_eq!(funcs[0].writes, vec!["total"]); - assert_eq!(funcs[0].reads, vec!["subtotal"]); - } - - #[test] - fn ivar_assignment_is_not_a_write() { - // `@x = …` is local memoization, not an AR attribute write. - let funcs = class_functions( - r#" -class M - def memo - @cache = expensive - end -end -"#, - ); - assert!( - funcs[0].writes.is_empty(), - "instance-var assignment must not be a field write; got {:?}", - funcs[0].writes, - ); - } - - #[test] - fn bare_and_self_mutator_emit_self_call() { - let funcs = class_functions( - r#" -class M - def persist - save - end - def persist_bang - self.save! - end -end -"#, - ); - let persist = funcs.iter().find(|f| f.name == "persist").unwrap(); - assert!( - persist.calls.contains(&"self.save".to_string()), - "bare `save` must be a self-call; got {:?}", - persist.calls, - ); - let persist_bang = funcs.iter().find(|f| f.name == "persist_bang").unwrap(); - assert!( - persist_bang.calls.contains(&"self.save!".to_string()), - "`self.save!` must be a self-call; got {:?}", - persist_bang.calls, - ); - } - - #[test] - fn receiver_mutator_emits_call() { - let funcs = class_functions( - r#" -class M - def touch_order(order) - order.update(state: "x") - User.create!(name: "y") - end -end -"#, - ); - assert!( - funcs[0].calls.contains(&"order.update".to_string()), - "local-receiver mutator must be captured; got {:?}", - funcs[0].calls, - ); - assert!( - funcs[0].calls.contains(&"User.create!".to_string()), - "const-receiver mutator must be captured; got {:?}", - funcs[0].calls, - ); - } - - #[test] - fn non_mutator_call_not_captured() { - let funcs = class_functions( - r#" -class M - def compute - helper.format(value) - end -end -"#, - ); - assert!( - funcs[0].calls.is_empty(), - "non-mutator calls must not be captured; got {:?}", - funcs[0].calls, - ); - } - - #[test] - fn operator_self_send_is_neither_write_nor_read() { - // `self == other` / `self <= other` are comparison operators whose - // method names end in `=` — they must NOT strip to a bogus write of a - // field named `=`/`<` (codex P2), nor be recorded as reads. - let funcs = class_functions( - r#" -class M - def ==(other) - self.id == other.id - end - def le(other) - self <= other - end -end -"#, - ); - let eq = funcs.iter().find(|f| f.name == "==").unwrap(); - assert!( - eq.writes.is_empty(), - "operator method must not produce a write; got {:?}", - eq.writes, - ); - // `self.id` IS a valid attribute read; the `==` operator send is not. - assert_eq!(eq.reads, vec!["id"]); - let le = funcs.iter().find(|f| f.name == "le").unwrap(); - assert!( - le.writes.is_empty() && le.reads.is_empty(), - "`self <= other` is neither write nor read; got writes {:?} reads {:?}", - le.writes, - le.reads, - ); - } -} diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/lib.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/lib.rs deleted file mode 100644 index 99518c9..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/lib.rs +++ /dev/null @@ -1,1265 +0,0 @@ -//! `ruff_ruby_spo` — Ruby/Rails frontend for the shared SPO triplet core. -//! -//! Walks an `app/models/` tree and produces a [`ModelGraph`] populated -//! with the AR-shape `Declaration` siblings the shared `ruff_spo_triplet` -//! crate expands into the 27 `OpenProject` AR-shape predicates (D-AR-3 in -//! the `openproject-ar-shape-extraction-v1` plan on lance-graph). -//! -//! # Architecture -//! -//! - [`mod@parse`] walks the directory, parses each `*.rb` with -//! `lib-ruby-parser`, and finds class definitions (recursing into -//! `module ... end` namespaces). One AST pass per file. -//! - [`mod@walk`] takes a class body and dispatches each top-level -//! `Send` call (`belongs_to :project`, `validates :x`, `acts_as_list`, -//! …) to the right [`Declaration`] variant by method-name match. -//! - [`extract`] unpacks each class's `declarations: Vec` -//! into the typed `Model::{associations, validations, callbacks, …}` -//! sibling slots the shared IR consumes. -//! -//! Method-body extraction ([`extract_fields`] / [`extract_functions`]) -//! is intentionally minimal in D-AR-3: the 100 % coverage gate (D-AR-4) -//! measures *declarations*, not field/function depth. The two body -//! extractors return empty vecs; the follow-up D-AR-3.5 implements them -//! against `Declaration::Attribute` + `Def` walks. -//! -//! Downstream consumers (`lance_graph` SPO loader, `action_emitter`, -//! `link_chain`) need ZERO changes — they already consume the triple -//! shape this crate targets via `ruff_spo_triplet::expand`. - -use std::path::Path; - -use ruff_spo_triplet::{ - ActsAs, AssocDecl, AttrDecl, Callback, ConcernRef, Delegation, DslCall, DynMethod, Field, - Function, GemDsl, Model, ModelGraph, ScopeDecl, StiInfo, UsingRef, Validation, -}; - -mod functions; -mod parse; -mod schema; -mod walk; - -pub use schema::{extract_app_with_schema, SchemaReport}; - -/// The namespace prefix for `OpenProject` subjects/objects. -pub const NAMESPACE: &str = "openproject"; - -/// A minimally-parsed Ruby class — what a parser frontend should produce -/// before the IR mapping. -/// -/// **Round-1 council ACK (prior-art-savant + dto-soa-savant):** the -/// `Vec` shape replaces the pre-existing -/// `associations: Vec` field. The 67-emit-category disambiguation -/// is done at parse time (one [`Declaration`] variant per category), not -/// by carrying a `body_source` blob and re-scanning it. This keeps the -/// frontend → IR projection a pure unpacking (`extract` below) and the -/// downstream `expand()` chain a pure projection. -#[derive(Debug, Clone, Default)] -pub struct RubyClass { - /// Class name as written (`WorkPackage`). No dots in Ruby class names, - /// so no normalisation needed (unlike Odoo's `account.move`). - pub name: String, - /// Every class-body DSL call, captured in source order. The - /// frontend-local discriminated union; the [`extract`] fn unpacks - /// this into the typed `Model::{associations, validations, …}` - /// sibling fields the shared IR consumes. - pub declarations: Vec, - /// Method-body extraction (D-AR-3.5): one [`Function`] per `def` - /// in the class body. Populated by [`parse::parse_models`] alongside - /// `declarations`, then flowed straight onto `Model::functions` by - /// [`extract`] below. - pub functions: Vec, -} - -/// One class-body DSL call, discriminated by category. -/// -/// **Frontend-local IR** (per Round-1 prior-art-savant verdict): the -/// shared `ruff_spo_triplet::Model` already carries 12 sibling-shape -/// `Vec<…>` fields + 1 `Option` per category. This enum is just -/// the in-source-order shape the parser emits *before* the [`extract`] -/// fn unpacks them. It is NOT exposed in any triple — it disappears at -/// the IR boundary. -#[derive(Debug, Clone)] -pub enum Declaration { - /// `belongs_to` / `has_many` / `has_one` / `has_and_belongs_to_many` / - /// `accepts_nested_attributes_for`. Distinct from body-walk - /// `traverses_relation` (which stays on `Function::traverses`). - Association(AssocDecl), - /// `validates` / `validate` / `normalizes` / `validates_associated` / - /// `validates_each`. - Validation(Validation), - /// `before_*` / `after_*` / `around_*` callback macros. - Callback(Callback), - /// `include` / `extend` / `prepend` / `class_methods do` / `included do`. - Concern(ConcernRef), - /// `attribute` / `attr_*` / `alias_*` / `serialize` / `enum` / - /// `store_attribute` / `store_accessor` / `define_attribute_method` / - /// `undef_method`. - Attribute(AttrDecl), - /// `delegate :foo, :bar, to: :baz`. - Delegation(Delegation), - /// `scope` / `default_scope` / `scopes` (OP plural). - Scope(ScopeDecl), - /// `acts_as_*` family. - ActsAs(ActsAs), - /// `OpenProject` custom DSL calls (`register_journal_formatter`, - /// `register_journal_formatted_fields`, plus the long-tail singletons). - DslCall(DslCall), - /// Third-party gem DSL (`mount_uploader`, `has_paper_trail`, …). - GemDsl(GemDsl), - /// `define_method` dynamic-method site. - DynamicMethod(DynMethod), - /// `using SomeRefinement`. - Using(UsingRef), - /// `class X < Parent` STI parent (also `abstract_class` / - /// `inheritance_column` metadata). - Sti(StiInfo), -} - -/// Top-level entry: walk a Rails `app/models/` tree and produce the IR -/// tagged with the default [`NAMESPACE`] (`"openproject"`). Thin wrapper -/// over [`extract_with`] for backward compatibility — every existing -/// caller (`ogar-from-rails::extract`) keeps working unchanged. -/// -/// New callers harvesting a non-OpenProject Rails curator (Redmine, Spree, -/// Open-Source-Billing, …) should use [`extract_with`] to tag the produced -/// `ModelGraph.namespace` correctly — otherwise downstream consumers see -/// every harvest prefixed `"openproject:"` regardless of source. -#[must_use] -pub fn extract(source_tree: &Path) -> ModelGraph { - extract_with(source_tree, NAMESPACE) -} - -/// Walk a Rails `app/models/` tree and produce the IR, tagging the -/// resulting [`ModelGraph`] with the caller-supplied `namespace`. The -/// namespace becomes the IRI prefix on every produced triple's subject / -/// object, so the same parser handles any Rails curator (`OpenProject`, -/// Redmine, Spree, Solidus, Open-Source-Billing, …) by simply passing the -/// curator's namespace string. -/// -/// The unpacking is mechanical: each [`Declaration`] variant lands in -/// its corresponding `Model::*` Vec field. Same-named [`RubyClass`] -/// entries (Ruby's `class X` reopen idiom across multiple files — -/// e.g. `OpenProject`'s `WorkPackage` reopened from -/// `app/models/work_package/inexistent_work_package.rb` etc.) are -/// merged into a single [`Model`] in source-file order: Vec fields -/// concatenate; `sti` is first-non-`None`-wins. -#[must_use] -pub fn extract_with(source_tree: &Path, namespace: &str) -> ModelGraph { - let classes = parse::parse_models(source_tree); - let mut graph = ModelGraph::new(namespace); - graph.models = build_models(&classes); - graph -} - -/// Walk an **arbitrary Rails app subtree** directly — `app/controllers`, -/// `app/models`, `app/jobs`, … — with no `app/models` suffix assumption, and -/// produce the IR. This is the **DO-arm / controller-harvest entry**: point it -/// at `app/controllers` and each controller's public actions land in -/// `Model::functions`, which `ogar_from_ruff::lift_actions` lifts to a -/// standalone `Vec` (the DO arm). `extract`/`extract_with` remain -/// the `app/models` (THINK-arm) specialisations. -#[must_use] -pub fn extract_tree_with(dir: &Path, namespace: &str) -> ModelGraph { - let classes = parse::parse_tree(dir); - let mut graph = ModelGraph::new(namespace); - graph.models = build_models(&classes); - graph -} - -/// Like [`extract_with`], but walks the **whole Rails application** — the -/// core `app/models` **plus every mounted engine's `app/models`** -/// (`modules/*/app/models`, `engines/*/app/models`). -/// -/// `OpenProject` keeps a large share of its domain in `modules/*` engines -/// (e.g. `TimeEntry` lives in `modules/costs/app/models`), invisible to -/// the core-only [`extract`]. All roots feed one `build_models` pass, so a -/// class reopened across engines still merges into a single [`Model`]. -/// -/// Plain [`extract`] / [`extract_with`] stay core-only for backward -/// compatibility; opt into engines explicitly with this entry point. -#[must_use] -pub fn extract_app_with(source_tree: &Path, namespace: &str) -> ModelGraph { - let classes = parse::parse_models_with_engines(source_tree); - let mut graph = ModelGraph::new(namespace); - graph.models = build_models(&classes); - graph -} - -/// Whole-application extraction (core + engines) tagged with the default -/// [`NAMESPACE`]. Thin wrapper over [`extract_app_with`]; see [`extract`] -/// for the namespace caveat. -#[must_use] -pub fn extract_app(source_tree: &Path) -> ModelGraph { - extract_app_with(source_tree, NAMESPACE) -} - -/// Build the deduplicated `Vec` from parsed [`RubyClass`]es — -/// merging same-named reopens into a single Model. -/// -/// Order: first occurrence of each name keeps its slot; later -/// occurrences merge in-place (Vec fields concatenate in encounter -/// order; `sti` is first-non-`None`-wins so empty reopens cannot -/// overwrite a previously-set inheritance fact). -fn build_models(classes: &[RubyClass]) -> Vec { - use std::collections::HashMap; - let mut models: Vec = Vec::with_capacity(classes.len()); - let mut index: HashMap = HashMap::with_capacity(classes.len()); - for class in classes { - let mut next = Model::new(&class.name); - next.fields = extract_fields(class); - // D-AR-3.5: method-name + raise/reads/traverses extraction - // already happened at parse time (see `parse.rs`). The class - // carries a populated `Function` vec. - next.functions.clone_from(&class.functions); - for decl in &class.declarations { - unpack_declaration(&mut next, decl); - } - if let Some(&slot) = index.get(&class.name) { - merge_model(&mut models[slot], next); - } else { - index.insert(class.name.clone(), models.len()); - models.push(next); - } - } - models -} - -/// Merge `src` into `dst` in-place. Used by [`build_models`] when a -/// later `class X` reopen extends an earlier definition: Vec fields -/// concatenate (preserving source-file order); `sti` is -/// first-non-`None`-wins (empty reopens cannot drop inheritance). -fn merge_model(dst: &mut Model, src: Model) { - dst.fields.extend(src.fields); - dst.functions.extend(src.functions); - dst.associations.extend(src.associations); - dst.validations.extend(src.validations); - dst.callbacks.extend(src.callbacks); - dst.concerns.extend(src.concerns); - dst.attributes.extend(src.attributes); - dst.delegations.extend(src.delegations); - dst.scopes.extend(src.scopes); - dst.acts_as.extend(src.acts_as); - dst.dsl_calls.extend(src.dsl_calls); - dst.gem_dsl.extend(src.gem_dsl); - dst.dynamic_methods.extend(src.dynamic_methods); - dst.refinements.extend(src.refinements); - if dst.sti.is_none() { - dst.sti = src.sti; - } - // C++ sibling Vecs are populated only by `ruff_cpp_spo`; Ruby - // extraction always leaves them empty, so extending is a no-op in - // practice but keeps the merge total over the `Model` shape. - dst.bases.extend(src.bases); - dst.member_fields.extend(src.member_fields); - dst.methods.extend(src.methods); - dst.templates.extend(src.templates); - dst.friends.extend(src.friends); - dst.macro_uses.extend(src.macro_uses); - dst.static_asserts.extend(src.static_asserts); -} - -/// Convenience: extract a single Ruby class from a source string. Used -/// by the synthetic coverage test (D-AR-4 partial); production callers -/// use [`extract`] over a tree. -#[doc(hidden)] -pub fn extract_from_source(src: &str) -> Vec { - parse::parse_models_from_source_for_test(src) -} - -/// The pure unpacking: route each [`Declaration`] into its typed -/// `Model::*` Vec / Option slot. No semantic transform here — this is -/// the seam between source-order parsing and category-grouped IR. -fn unpack_declaration(model: &mut Model, decl: &Declaration) { - match decl { - Declaration::Association(a) => model.associations.push(a.clone()), - Declaration::Validation(v) => model.validations.push(v.clone()), - Declaration::Callback(cb) => model.callbacks.push(cb.clone()), - Declaration::Concern(cr) => model.concerns.push(cr.clone()), - Declaration::Attribute(a) => model.attributes.push(a.clone()), - Declaration::Delegation(d) => model.delegations.push(d.clone()), - Declaration::Scope(s) => model.scopes.push(s.clone()), - Declaration::ActsAs(aa) => model.acts_as.push(aa.clone()), - Declaration::DslCall(dc) => model.dsl_calls.push(dc.clone()), - Declaration::GemDsl(g) => model.gem_dsl.push(g.clone()), - Declaration::DynamicMethod(dm) => model.dynamic_methods.push(dm.clone()), - Declaration::Using(u) => model.refinements.push(u.clone()), - Declaration::Sti(sti) => model.sti = Some(sti.clone()), - } -} - -/// Extract [`Field`]s from a class. **D-AR-3 stub** — returns empty. -/// -/// The full implementation (D-AR-3.5) will derive fields from: -/// - DB columns via `db/schema.rb` parsing, -/// - [`Declaration::Attribute`] entries whose kind is -/// `Attribute` / `AttrAccessor` / `StoreAccessor` / etc., -/// - memoized/derived method assignments (the `emitted_by` link). -/// -/// The D-AR-4 coverage gate measures *declarations*, not fields, so the -/// stub is sufficient for ndjson + `expand()` shipment of AR-shape facts. -fn extract_fields(_class: &RubyClass) -> Vec { - Vec::new() -} - -#[cfg(test)] -mod tests { - use super::*; - use ruff_spo_triplet::{ - AssocDecl, AssocKind, AttrDecl, AttrKind, Callback, ConcernKind, ConcernRef, expand, - }; - - /// Locked target shape: a hand-built `ModelGraph` matching what a - /// finished `extract()` MUST produce for a tiny OpenProject-like model. - /// This test passes today (it does not call the `todo!()` extractors); - /// it tells the frontend author what "done" looks like. - fn locked_work_package_graph() -> ModelGraph { - let mut graph = ModelGraph::new(NAMESPACE); - graph.models.push(Model { - name: "WorkPackage".to_string(), - fields: vec![Field { - name: "total_hours".to_string(), - depends_on: vec!["time_entries.hours".to_string()], - emitted_by: Some("compute_total_hours".to_string()), - ..Default::default() - }], - functions: vec![Function { - name: "compute_total_hours".to_string(), - reads: vec!["status".to_string()], - raises: vec!["ActiveRecord::RecordInvalid".to_string()], - traverses: vec!["time_entries".to_string()], - ..Default::default() - }], - ..Default::default() - }); - graph - } - - #[test] - fn locked_shape_expands_to_expected_triples() { - let triples = expand(&locked_work_package_graph()); - let has = - |s: &str, p: &str, o: &str| triples.iter().any(|t| t.s == s && t.p == p && t.o == o); - - // ObjectType / Property / Function classification. - assert!(has( - "openproject:WorkPackage", - "rdf:type", - "ogit:ObjectType" - )); - assert!(has( - "openproject:WorkPackage.total_hours", - "rdf:type", - "ogit:Property" - )); - assert!(has( - "openproject:WorkPackage.compute_total_hours", - "rdf:type", - "ogit:Function" - )); - // Compute graph edges. - assert!(has( - "openproject:WorkPackage.total_hours", - "emitted_by", - "openproject:WorkPackage.compute_total_hours" - )); - assert!(has( - "openproject:WorkPackage.total_hours", - "depends_on", - "openproject:WorkPackage.time_entries.hours" - )); - // Guard + traversal. - assert!(has( - "openproject:WorkPackage.compute_total_hours", - "raises", - "exc:ActiveRecord::RecordInvalid" - )); - assert!(has( - "openproject:WorkPackage.compute_total_hours", - "traverses_relation", - "openproject:WorkPackage.time_entries" - )); - } - - #[test] - fn namespace_is_openproject() { - let triples = expand(&locked_work_package_graph()); - assert!( - triples - .iter() - .all(|t| { t.s.starts_with("openproject:") || t.s.starts_with("exc:") }) - ); - } - - /// `extract` (no-arg-namespace form) tags the graph with the default - /// [`NAMESPACE`] for back-compat — existing callers keep working. - #[test] - fn extract_defaults_to_openproject_namespace() { - // A non-existent path returns an empty `ModelGraph` (no panic); - // the namespace tag is still set correctly. - let g = extract(std::path::Path::new("/__nonexistent_for_ruff_test__")); - assert_eq!(g.namespace, NAMESPACE); - assert_eq!(g.namespace, "openproject"); - } - - /// `extract_with` tags the graph with the caller-supplied namespace, - /// so a Redmine / Spree / OSB harvest is not silently prefixed - /// `openproject:`. Pins the new API for the curator-namespace use case. - #[test] - fn extract_with_tags_caller_supplied_namespace() { - let g = extract_with( - std::path::Path::new("/__nonexistent_for_ruff_test__"), - "redmine", - ); - assert_eq!(g.namespace, "redmine"); - let g = extract_with( - std::path::Path::new("/__nonexistent_for_ruff_test__"), - "osb", - ); - assert_eq!(g.namespace, "osb"); - } - - /// Unpacking lock: a fully-populated `RubyClass.declarations` list - /// must end up in the corresponding `Model::*` Vec slots after - /// `unpack_declaration` runs across every variant. This guards the - /// frontend→IR seam against drift if a new `Declaration` variant is - /// added without a routing arm. - #[test] - fn declarations_unpack_into_typed_model_slots() { - let mut model = Model::new("Sample"); - let decls = vec![ - Declaration::Association(AssocDecl { - kind: AssocKind::BelongsTo, - name: "project".to_string(), - options: vec![], - }), - Declaration::Callback(Callback { - phase: "before_save".to_string(), - target: "tidy_up".to_string(), - options: vec![], - }), - Declaration::Concern(ConcernRef { - kind: ConcernKind::Include, - module: "Acts::Customizable".to_string(), - body_ref: None, - }), - Declaration::Attribute(AttrDecl { - kind: AttrKind::AttrAccessor, - name: "virtual_flag".to_string(), - options: vec![], - }), - ]; - for d in &decls { - unpack_declaration(&mut model, d); - } - assert_eq!(model.associations.len(), 1); - assert_eq!(model.callbacks.len(), 1); - assert_eq!(model.concerns.len(), 1); - assert_eq!(model.attributes.len(), 1); - // The unrouted slots stay empty. - assert!(model.validations.is_empty()); - assert!(model.acts_as.is_empty()); - assert!(model.dsl_calls.is_empty()); - assert!(model.sti.is_none()); - } - - // ────────────────── D-AR-3 + D-AR-4 coverage tests ────────────────── - - /// **D-AR-4 synthetic fixture** — a single Ruby source string that - /// exercises every routed DSL category. Asserts the extractor finds - /// at least one declaration per `Declaration` variant. This is the - /// hermetic 100 %-coverage gate (runs in `cargo test` without - /// needing the `OpenProject` corpus on disk). - /// - /// If a new DSL name is added to `walk::route_send` and forgotten - /// here, this test still passes — the corpus assertion below is - /// the second gate. But forgetting a Declaration *variant* trips - /// this test loudly. - #[test] - fn ar_shape_synthetic_fixture_exercises_every_declaration_variant() { - // Carefully crafted to exercise: - // - all 5 AssocKind variants - // - 3 Validation kinds (Validates / Validate / Normalizes) - // - 1 Callback (before_save) - // - 3 Concern kinds (Include / Extend / ClassMethodsBlock via block form) - // - 4 Attribute kinds (Attribute / AttrAccessor / AliasAttribute / Serialize) - // - Delegation, Scope (regular + default + plural), ActsAs, - // DslCall (catch-all + 2 promoted), GemDsl, DynamicMethod, - // Using, Sti. - let src = r#" -class WorkPackage < Issue - belongs_to :project - has_many :time_entries - has_one :budget - has_and_belongs_to_many :watchers - accepts_nested_attributes_for :children - - validates :subject, presence: true - validate :custom_rule - normalizes :email, with: ->(v) { v.downcase } - - before_save :tidy_up - - include Acts::Customizable - extend Pagination::Model - class_methods do - def klass_method; end - end - - attribute :estimated_hours - attr_accessor :virtual - alias_attribute :title, :subject - serialize :preferences, JSON - - delegate :name, :identifier, to: :project, prefix: true - - scope :open, -> { where(closed: false) } - default_scope -> { order(:id) } - scopes :by_priority, :by_status - - acts_as_list scope: :project_id - acts_as_watchable - - register_journal_formatter :diff, :custom - register_journal_formatted_fields :subject - activity_provider_for :work_packages - random_unmapped_dsl :foo - - mount_uploader :avatar, AvatarUploader - has_paper_trail on: [:update] - - define_method(:dynamic) { puts "hi" } - - using OpenProject::DateRange -end -"#; - let classes = extract_from_source(src); - assert_eq!(classes.len(), 1, "should find exactly one class"); - let cls = &classes[0]; - assert_eq!(cls.name, "WorkPackage"); - - // Count each Declaration variant. - let mut assoc = 0; - let mut valid = 0; - let mut cbk = 0; - let mut concern = 0; - let mut attr = 0; - let mut deleg = 0; - let mut scope = 0; - let mut acts_as = 0; - let mut dsl = 0; - let mut gem = 0; - let mut dynm = 0; - let mut using = 0; - let mut sti = 0; - for d in &cls.declarations { - match d { - Declaration::Association(_) => assoc += 1, - Declaration::Validation(_) => valid += 1, - Declaration::Callback(_) => cbk += 1, - Declaration::Concern(_) => concern += 1, - Declaration::Attribute(_) => attr += 1, - Declaration::Delegation(_) => deleg += 1, - Declaration::Scope(_) => scope += 1, - Declaration::ActsAs(_) => acts_as += 1, - Declaration::DslCall(_) => dsl += 1, - Declaration::GemDsl(_) => gem += 1, - Declaration::DynamicMethod(_) => dynm += 1, - Declaration::Using(_) => using += 1, - Declaration::Sti(_) => sti += 1, - } - } - // Every Declaration variant gets at least one hit. - assert_eq!(assoc, 5, "5 association macros"); - assert_eq!(valid, 3, "validates + validate + normalizes"); - assert!(cbk >= 1, "at least 1 callback"); - assert!(concern >= 3, "include + extend + class_methods do"); - assert_eq!(attr, 4, "attribute + attr_accessor + alias_attribute + serialize"); - assert_eq!(deleg, 1, "delegate :name, :identifier, to:"); - assert!(scope >= 4, "1 scope + 1 default + 2 in scopes plural"); - assert_eq!(acts_as, 2, "acts_as_list + acts_as_watchable"); - // dsl_calls: register_journal_formatter, register_journal_formatted_fields, - // activity_provider_for, random_unmapped_dsl (catch-all) - assert!(dsl >= 4, "promoted + long-tail + catch-all"); - assert_eq!(gem, 2, "mount_uploader + has_paper_trail"); - assert_eq!(dynm, 1, "define_method"); - assert_eq!(using, 1, "using OpenProject::DateRange"); - assert_eq!(sti, 1, "STI parent (Issue) recorded"); - } - - /// **D-AR-4 routing-table lock** — the catch-all `has_dsl_call` must - /// not absorb any name that has its own discriminated predicate. - /// Promoted names (`register_journal_formatter`, - /// `register_journal_formatted_fields`) intentionally go through the - /// `DslCall` variant *with their own name* — the expander routes - /// them to `RegistersJournalFormatter` / `RegistersJournalFormattedFields` - /// in `ruff_spo_triplet::expand`. This test asserts the names in - /// the long-tail catch-all are EITHER known promoted names OR - /// genuinely-unknown DSL. - #[test] - fn ar_shape_dsl_catchall_does_not_steal_discriminated_names() { - // The §2 closed-vocab names that have their OWN discriminated - // Declaration variant (not DslCall). If any of these end up in - // the catch-all DslCall, the walker's routing is broken. - let discriminated_names = [ - // Associations (Declaration::Association) - "belongs_to", - "has_many", - "has_one", - "has_and_belongs_to_many", - "accepts_nested_attributes_for", - // Validations - "validates", - "validate", - "normalizes", - "validates_associated", - "validates_each", - // Callbacks (any of the 13+ phases) - "before_save", - "after_create", - // Concerns - "include", - "extend", - "prepend", - // Attributes - "attribute", - "attr_accessor", - "attr_reader", - "alias_attribute", - "alias_method", - "serialize", - "enum", - "store_attribute", - "store_accessor", - // Delegation - "delegate", - // Scopes - "scope", - "default_scope", - "scopes", - // Acts_as (any acts_as_*) - "acts_as_list", - "acts_as_watchable", - // Gem DSL - "mount_uploader", - "has_paper_trail", - "has_closure_tree", - "counter_culture", - "auto_strip_attributes", - // Metaprogramming - "define_method", - // Refinements - "using", - ]; - // Source pulling every discriminated name + one genuinely-unknown - // one that SHOULD land in the catch-all. - let src = r#" -class Sample - belongs_to :project - has_many :children - has_one :parent - has_and_belongs_to_many :tags - accepts_nested_attributes_for :items - validates :name - validate :rule - normalizes :email - validates_associated :rel - validates_each :a - before_save :hook - after_create :hook - include ModX - extend ModY - prepend ModZ - attribute :a - attr_accessor :b - attr_reader :c - alias_attribute :d, :e - alias_method :f, :g - serialize :h - enum :i - store_attribute :j, :k - store_accessor :l, :m - delegate :n, to: :o - scope :p, -> { } - default_scope -> { } - scopes :q, :r - acts_as_list - acts_as_watchable - mount_uploader :s - has_paper_trail - has_closure_tree - counter_culture :t - auto_strip_attributes :u - define_method(:v) { } - using Mod - totally_unknown_op_dsl :catchall -end -"#; - let classes = extract_from_source(src); - assert_eq!(classes.len(), 1); - let cls = &classes[0]; - let dsl_names: std::collections::HashSet<&str> = cls - .declarations - .iter() - .filter_map(|d| match d { - Declaration::DslCall(dc) => Some(dc.name.as_str()), - _ => None, - }) - .collect(); - // Every discriminated name must NOT appear in the catch-all. - for name in discriminated_names { - assert!( - !dsl_names.contains(name), - "name `{name}` leaked into has_dsl_call catch-all — routing bug", - ); - } - // The genuinely-unknown one DOES land in catch-all. - assert!( - dsl_names.contains("totally_unknown_op_dsl"), - "unknown DSL must land in has_dsl_call catch-all", - ); - } - - /// **D-AR-4 real-corpus coverage gate.** Runs over the `OpenProject` - /// `app/models/` tree at `$OPENPROJECT_PATH`. Conditional on the - /// env var being set so CI doesn't require the corpus checked out. - /// - /// Asserts: - /// - between 900 and 1100 classes extracted (measured baseline: - /// 941 files / ~960 class defs incl. STI subclasses), - /// - the catch-all `has_dsl_call` only carries genuinely-unknown - /// names (none of the §2 discriminated names), which is the - /// 100 %-coverage gate. - #[test] - #[allow(clippy::print_stderr)] // diagnostic emission gated on env var (real-corpus gate) - fn ar_shape_real_corpus_coverage_gate() { - let Ok(root) = std::env::var("OPENPROJECT_PATH") else { - eprintln!("OPENPROJECT_PATH unset; skipping real-corpus gate"); - return; - }; - let graph = extract(std::path::Path::new(&root)); - let class_count = graph.models.len(); - assert!( - (500..=1200).contains(&class_count), - "class count {class_count} outside expected band (measured file baseline = 941; \ - class count is lower because some files are modules-only and some files contain multiple classes)", - ); - // No discriminated name should have leaked to the catch-all - // (same set as the synthetic test above, in inline form). - let leaked: Vec = graph - .models - .iter() - .flat_map(|m| m.dsl_calls.iter().map(|d| d.name.clone())) - .filter(|n| { - matches!( - n.as_str(), - "belongs_to" - | "has_many" - | "has_one" - | "has_and_belongs_to_many" - | "accepts_nested_attributes_for" - | "validates" - | "validate" - | "normalizes" - | "include" - | "extend" - | "prepend" - | "delegate" - | "scope" - | "default_scope" - | "define_method" - | "using" - ) - }) - .collect(); - assert!( - leaked.is_empty(), - "discriminated names leaked to has_dsl_call catch-all: {leaked:?}", - ); - // Total declarations across all classes — sanity bound from the - // §2 census (1696 measured; allow drift). - let total_decls: usize = graph - .models - .iter() - .map(|m| { - m.associations.len() - + m.validations.len() - + m.callbacks.len() - + m.concerns.len() - + m.attributes.len() - + m.delegations.len() - + m.scopes.len() - + m.acts_as.len() - + m.dsl_calls.len() - + m.gem_dsl.len() - + m.dynamic_methods.len() - + m.refinements.len() - + usize::from(m.sti.is_some()) - }) - .sum(); - assert!( - (1000..=2500).contains(&total_decls), - "declaration count {total_decls} outside expected band (measured baseline ≈ 1696)", - ); - eprintln!( - "D-AR-4 corpus gate: {class_count} classes, {total_decls} declarations, 0 leaked names", - ); - } - - /// Engine-walking: [`extract_app`] harvests core `app/models` PLUS every - /// mounted engine's `app/models`, and a class reopened across roots - /// merges into one `Model` (cross-root reopen-merge). Plain [`extract`] - /// stays core-only. - #[test] - fn extract_app_walks_engines_and_merges_across_roots() { - use std::fs; - let base = std::env::temp_dir().join(format!("ruff_engines_{}", std::process::id())); - let _ = fs::remove_dir_all(&base); - let core = base.join("app/models"); - let engine = base.join("modules/costs/app/models"); - fs::create_dir_all(&core).unwrap(); - fs::create_dir_all(&engine).unwrap(); - fs::write( - core.join("project.rb"), - "class Project < ApplicationRecord\n has_many :issues\nend\n", - ) - .unwrap(); - // Engine-only model — invisible to core-only `extract`. - fs::write( - engine.join("time_entry.rb"), - "class TimeEntry < ApplicationRecord\n belongs_to :project\nend\n", - ) - .unwrap(); - // Same class reopened in BOTH roots — must merge into one Model. - fs::write( - core.join("user.rb"), - "class User < ApplicationRecord\n has_many :members\nend\n", - ) - .unwrap(); - fs::write( - engine.join("user_costs.rb"), - "class User < ApplicationRecord\n has_many :time_entries\nend\n", - ) - .unwrap(); - - // core-only extract misses the engine model. - let core_only = extract(&base); - assert!(core_only.models.iter().any(|m| m.name == "Project")); - assert!( - !core_only.models.iter().any(|m| m.name == "TimeEntry"), - "core-only extract must NOT see the engine model", - ); - - // extract_app sees both, namespace-tagged, and merges the reopen. - let app = extract_app_with(&base, "openproject"); - assert_eq!(app.namespace, "openproject"); - assert!(app.models.iter().any(|m| m.name == "Project")); - assert!( - app.models.iter().any(|m| m.name == "TimeEntry"), - "extract_app must harvest modules/*/app/models", - ); - let users: Vec<&Model> = app.models.iter().filter(|m| m.name == "User").collect(); - assert_eq!(users.len(), 1, "cross-root reopen must merge into ONE Model"); - assert_eq!( - users[0].associations.len(), - 2, - "both roots' associations must be merged", - ); - - let _ = fs::remove_dir_all(&base); - } - - /// Real-corpus engine gate: on `OpenProject`, [`extract_app`] (core + - /// engines) harvests strictly more than core-only [`extract`], and - /// surfaces an engine-only model (`TimeEntry` lives in - /// `modules/costs/app/models`). Env-gated like the coverage gate. - #[test] - #[allow(clippy::print_stderr)] - fn extract_app_harvests_openproject_engines() { - let Ok(root) = std::env::var("OPENPROJECT_PATH") else { - eprintln!("OPENPROJECT_PATH unset; skipping engine real-corpus gate"); - return; - }; - let path = std::path::Path::new(&root); - let core = extract(path).models.len(); - let app = extract_app(path); - let app_count = app.models.len(); - assert!( - app_count > core, - "extract_app ({app_count}) must exceed core-only ({core})", - ); - assert!( - app.models.iter().any(|m| m.name == "TimeEntry"), - "TimeEntry (modules/costs/app/models) must be harvested by extract_app", - ); - eprintln!("engine gate: core={core}, core+engines={app_count}"); - } - - // ────────────────── Codex P2 regression tests ────────────────── - - /// **Codex P2 (PR #6)** — `module Foo; class Bar < ApplicationRecord; end; end` - /// must yield `name = "Foo::Bar"`, not just `"Bar"`. Otherwise - /// distinct namespaced models with the same inner class name - /// collide in the SPO graph. - #[test] - fn module_namespace_qualifies_inner_class_name() { - let src = r#" -module Foo - class Bar < ApplicationRecord - end -end - -module Foo - module Inner - class Bar < ApplicationRecord - end - end -end -"#; - let classes = extract_from_source(src); - let names: Vec<&str> = classes.iter().map(|c| c.name.as_str()).collect(); - assert!( - names.contains(&"Foo::Bar"), - "expected Foo::Bar; got {names:?}" - ); - assert!( - names.contains(&"Foo::Inner::Bar"), - "expected Foo::Inner::Bar; got {names:?}" - ); - // The two `Bar`s must NOT collide. - assert_eq!(names.iter().filter(|n| n.ends_with("::Bar")).count(), 2); - } - - /// **Codex P2 (PR #6)** — `has_many :items, -> { active }, dependent: :destroy` - /// puts the options hash at args[2] (the lambda is args[1]). The - /// scoped-association form was silently dropping the options. - #[test] - fn scoped_association_picks_up_trailing_options() { - let src = r#" -class WorkPackage < ApplicationRecord - has_many :items, -> { where(active: true) }, dependent: :destroy, class_name: "Item" -end -"#; - let classes = extract_from_source(src); - let assoc = classes[0] - .declarations - .iter() - .find_map(|d| match d { - Declaration::Association(a) => Some(a), - _ => None, - }) - .expect("expected an association"); - assert_eq!(assoc.name, "items"); - let opt_keys: Vec<&str> = - assoc.options.iter().map(|(k, _)| k.as_str()).collect(); - assert!( - opt_keys.contains(&"dependent"), - "expected `dependent` in options; got {opt_keys:?}", - ); - assert!( - opt_keys.contains(&"class_name"), - "expected `class_name` in options; got {opt_keys:?}", - ); - } - - /// **Codex P2 (PR #6)** — `attribute :age, :integer` must NOT emit - /// `integer` as a bogus attribute. Only `age` is a real attribute; - /// `:integer` is the type metadata. - #[test] - fn attribute_macro_does_not_emit_type_as_attribute() { - let src = r#" -class M < ApplicationRecord - attribute :age, :integer - serialize :data, JSON - enum :status, { active: 0, archived: 1 } -end -"#; - let classes = extract_from_source(src); - let attrs: Vec<&str> = classes[0] - .declarations - .iter() - .filter_map(|d| match d { - Declaration::Attribute(a) => Some(a.name.as_str()), - _ => None, - }) - .collect(); - assert!(attrs.contains(&"age"), "age must be extracted: {attrs:?}"); - assert!(attrs.contains(&"data"), "data must be extracted"); - assert!(attrs.contains(&"status"), "status must be extracted"); - // The type/class/hash MUST NOT leak as attributes. - assert!( - !attrs.contains(&"integer"), - "bogus `integer` attribute: {attrs:?}" - ); - assert!( - !attrs.contains(&"JSON"), - "bogus `JSON` attribute: {attrs:?}" - ); - } - - /// **Codex P2 (PR #6)** — `store_accessor :store_key, :a, :b, :c` - /// must NOT emit `store_key` as an attribute. Only `:a`, `:b`, - /// `:c` are real attributes; `:store_key` is the store column. - #[test] - fn store_accessor_does_not_emit_store_key_as_attribute() { - let src = r#" -class M < ApplicationRecord - store_accessor :preferences, :theme, :language - store_attribute :preferences, :font_size, :integer -end -"#; - let classes = extract_from_source(src); - let attrs: Vec<&str> = classes[0] - .declarations - .iter() - .filter_map(|d| match d { - Declaration::Attribute(a) => Some(a.name.as_str()), - _ => None, - }) - .collect(); - assert!(attrs.contains(&"theme"), "theme must be extracted: {attrs:?}"); - assert!(attrs.contains(&"language")); - assert!(attrs.contains(&"font_size")); - // Store key MUST NOT leak. - assert!( - !attrs.contains(&"preferences"), - "store key `preferences` leaked: {attrs:?}", - ); - // Type MUST NOT leak. - assert!( - !attrs.contains(&"integer"), - "bogus `integer` attribute: {attrs:?}", - ); - } - - /// **Codex P2 (PR #6)** — `with_options presence: true do; validates :name; end` - /// must NOT lose the inner validations. The wrapper block's body - /// is recursed into so declarations inside grouping blocks land - /// on the model. - #[test] - fn with_options_grouping_block_recurses_into_body() { - let src = r#" -class M < ApplicationRecord - with_options presence: true do - validates :name - validates :subject - end -end -"#; - let classes = extract_from_source(src); - let validations: Vec<&str> = classes[0] - .declarations - .iter() - .filter_map(|d| match d { - Declaration::Validation(v) => Some(v.target.as_str()), - _ => None, - }) - .collect(); - assert!( - validations.contains(&"name"), - "validates :name lost from with_options body: {validations:?}", - ); - assert!(validations.contains(&"subject")); - } - - /// **Codex P2 (PR #6)** — Ruby's `alias new_name old_name` keyword - /// form parses as `Node::Alias`, NOT as a `Send`. The walker now - /// recognises it and emits an `Attribute::Alias` declaration. - #[test] - fn ruby_alias_keyword_emits_alias_declaration() { - let src = r#" -class M < ApplicationRecord - def original; end - alias new_method original -end -"#; - let classes = extract_from_source(src); - let aliases: Vec<&str> = classes[0] - .declarations - .iter() - .filter_map(|d| match d { - Declaration::Attribute(a) - if matches!(a.kind, ruff_spo_triplet::AttrKind::Alias) => - { - Some(a.name.as_str()) - } - _ => None, - }) - .collect(); - assert!( - aliases.contains(&"new_method=original"), - "alias keyword not captured: {aliases:?}", - ); - } - - /// **D-AR-5.2** — `attribute :age, :integer` puts `:integer` as a - /// Sym at args[1]. The walker now lifts it into the `AttrDecl` - /// `options` as `("type", "integer")` so the expander can emit a - /// `field_type` triple. - #[test] - fn attribute_macro_extracts_positional_type_into_options() { - let src = r#" -class M < ApplicationRecord - attribute :age, :integer - attribute :name, :string, default: "" - store_attribute :prefs, :font_size, :integer - attribute :no_type -end -"#; - let classes = extract_from_source(src); - let attrs: Vec<(&str, Option<&str>)> = classes[0] - .declarations - .iter() - .filter_map(|d| match d { - Declaration::Attribute(a) => Some(( - a.name.as_str(), - a.options - .iter() - .find(|(k, _)| k == "type") - .map(|(_, v)| v.as_str()), - )), - _ => None, - }) - .collect(); - assert!( - attrs.contains(&("age", Some("integer"))), - "age must carry `integer` type; got {attrs:?}", - ); - assert!( - attrs.contains(&("name", Some("string"))), - "name must carry `string` type", - ); - assert!( - attrs.contains(&("font_size", Some("integer"))), - "store_attribute's attr must carry `integer` type", - ); - assert!( - attrs.contains(&("no_type", None)), - "untyped attribute must carry no type option", - ); - } - - // ───── reopen-merge tests (the OP `WorkPackage` regression) ───── - - /// Two `class WorkPackage` declarations across files must produce ONE - /// `Model` whose Vec fields concatenate, not two duplicate entries. - /// Repro for the `OpenProject` case: an empty reopener (from - /// `app/models/work_package/inexistent_work_package.rb` etc.) was - /// being emitted as a separate `Model { name: "WorkPackage", … }` - /// alongside the rich one — and a naïve `.find(|c| c.name == "WorkPackage")` - /// would land on the empty side. - #[test] - fn build_models_merges_same_named_reopens_into_one() { - let empty_reopener = RubyClass { - name: "WorkPackage".to_string(), - declarations: Vec::new(), - functions: Vec::new(), - }; - let rich = RubyClass { - name: "WorkPackage".to_string(), - declarations: vec![ - Declaration::Association(AssocDecl { - kind: AssocKind::BelongsTo, - name: "project".to_string(), - options: Vec::new(), - }), - Declaration::Concern(ConcernRef { - kind: ConcernKind::Include, - module: "WorkPackage::Validations".to_string(), - body_ref: None, - }), - Declaration::Attribute(AttrDecl { - kind: AttrKind::Attribute, - name: "subject".to_string(), - options: Vec::new(), - }), - ], - functions: Vec::new(), - }; - let models = build_models(&[empty_reopener, rich]); - - // Exactly one Model with the qualified name — not two. - assert_eq!(models.iter().filter(|m| m.name == "WorkPackage").count(), 1); - let wp = models.iter().find(|m| m.name == "WorkPackage").unwrap(); - // Merged content from the rich reopener, undisturbed by the empty one. - assert_eq!(wp.associations.len(), 1); - assert_eq!(wp.associations[0].name, "project"); - assert_eq!(wp.concerns.len(), 1); - assert_eq!(wp.attributes.len(), 1); - } - - /// First-occurrence wins for the [`Model`] slot; later reopens append - /// in source-file order. Mirrors the directory walk that produces the - /// reopener BEFORE the main file alphabetically - /// (e.g. `work_package/inexistent_work_package.rb` sorts before - /// `work_package.rb`). - #[test] - fn build_models_preserves_first_occurrence_slot_and_source_order() { - let first = RubyClass { - name: "WorkPackage".to_string(), - declarations: vec![Declaration::Association(AssocDecl { - kind: AssocKind::BelongsTo, - name: "project".to_string(), - options: Vec::new(), - })], - functions: Vec::new(), - }; - let second = RubyClass { - name: "Other".to_string(), - declarations: Vec::new(), - functions: Vec::new(), - }; - let third = RubyClass { - name: "WorkPackage".to_string(), - declarations: vec![Declaration::Association(AssocDecl { - kind: AssocKind::BelongsTo, - name: "author".to_string(), - options: Vec::new(), - })], - functions: Vec::new(), - }; - let models = build_models(&[first, second, third]); - // No duplicate slot for WorkPackage; "Other" sits between the two - // reopens in the input but the merged WorkPackage keeps the - // first-occurrence slot (index 0). - assert_eq!(models.len(), 2); - assert_eq!(models[0].name, "WorkPackage"); - assert_eq!(models[1].name, "Other"); - // Concatenation in encounter order: project, then author. - assert_eq!( - models[0] - .associations - .iter() - .map(|a| a.name.as_str()) - .collect::>(), - vec!["project", "author"], - ); - } - - /// STI is first-non-`None`-wins so an empty reopener cannot strip - /// inheritance from a class that earlier declared it. - #[test] - fn build_models_sti_first_non_none_wins() { - let with_sti = RubyClass { - name: "Article".to_string(), - declarations: vec![Declaration::Sti(ruff_spo_triplet::StiInfo { - inherits_from: Some("ApplicationRecord".to_string()), - abstract_class: false, - inheritance_column: None, - })], - functions: Vec::new(), - }; - let empty_reopen = RubyClass { - name: "Article".to_string(), - declarations: Vec::new(), - functions: Vec::new(), - }; - let models = build_models(&[with_sti, empty_reopen]); - assert_eq!(models.len(), 1); - assert!(models[0].sti.is_some(), "STI must survive an empty reopen"); - assert_eq!( - models[0].sti.as_ref().unwrap().inherits_from.as_deref(), - Some("ApplicationRecord"), - ); - } -} diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/parse.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/parse.rs deleted file mode 100644 index 64e4074..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/parse.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! `parse_models` — walk a Rails `app/models/` tree and produce -//! [`crate::RubyClass`] records. -//! -//! Single AST pass per file via `lib-ruby-parser`. Class discovery -//! recurses into `module ... end` so nested namespaces (e.g. -//! `module OpenProject; module Acts; class Foo`) yield the inner class. -//! STI is captured via the superclass node (passed to [`crate::walk`]). - -use std::fs; -use std::path::{Path, PathBuf}; - -use lib_ruby_parser::{Node, Parser, ParserOptions}; - -use crate::RubyClass; -use crate::functions::extract_functions_from_body; -use crate::walk::walk_class_body; - -/// Walk `/app/models/**/*.rb` and parse every file into -/// the [`RubyClass`] discriminated-Declaration shape. -/// -/// Files that fail to parse are skipped (lib-ruby-parser still returns -/// a `ParserResult` with `ast: None` and a `Diagnostic` vec on hard -/// failures; this fn drops those silently — the coverage test -/// surfaces lost-class counts). -/// -/// Returned classes are in deterministic order: file path (ASCII sort), -/// then declaration order within a file. -pub(crate) fn parse_models(source_tree: &Path) -> Vec { - parse_tree(source_tree.join("app/models").as_path()) -} - -/// Walk `/**/*.rb` directly — any Rails app subtree, not just -/// `app/models` — and parse every file into the [`RubyClass`] shape. -/// [`parse_models`] is the `app/models` specialisation; the DO-arm / -/// controller harvest points this at `app/controllers`, where each -/// controller's public actions land in the class's `functions`. -pub(crate) fn parse_tree(dir: &Path) -> Vec { - let mut files: Vec = collect_rb_files(dir); - files.sort(); - let mut classes = Vec::with_capacity(files.len()); - for path in files { - let Ok(src) = fs::read_to_string(&path) else { - continue; - }; - let Some(ast) = parse_file(&src, &path) else { - continue; - }; - collect_classes_from_node(&ast, &mut classes); - } - classes -} - -/// Walk a Rails application **including mounted engines** — the core -/// `/app/models` plus every engine's `app/models` -/// (`OpenProject` keeps ~half its domain in `modules/*/app/models`; generic -/// Rails engines live in `engines/*/app/models`). All files feed one -/// [`super::build_models`] pass, so the reopen-merge works across roots too. -/// -/// Returns classes in deterministic order: path (ASCII sort) across all -/// roots, then declaration order within a file. -pub(crate) fn parse_models_with_engines(source_tree: &Path) -> Vec { - let mut files: Vec = Vec::new(); - for root in collect_model_roots(source_tree) { - walk_rb(&root, &mut files); - } - files.sort(); - files.dedup(); - let mut classes = Vec::with_capacity(files.len()); - for path in files { - let Ok(src) = fs::read_to_string(&path) else { - continue; - }; - let Some(ast) = parse_file(&src, &path) else { - continue; - }; - collect_classes_from_node(&ast, &mut classes); - } - classes -} - -/// Every Rails `app/models` root under `source_tree`: the core app plus -/// each mounted engine. Bounded by convention (`modules/*`, `engines/*`) -/// rather than a full-tree scan, so vendored gems and spec fixtures are -/// never mistaken for application model roots. -fn collect_model_roots(source_tree: &Path) -> Vec { - let mut roots = Vec::new(); - let core = source_tree.join("app/models"); - if core.is_dir() { - roots.push(core); - } - for engines_parent in ["modules", "engines"] { - let Ok(entries) = fs::read_dir(source_tree.join(engines_parent)) else { - continue; - }; - for entry in entries.flatten() { - let models = entry.path().join("app/models"); - if models.is_dir() { - roots.push(models); - } - } - } - roots.sort(); - roots -} - -/// Recursively collect all `*.rb` paths under `dir`. Empty if `dir` -/// doesn't exist or isn't readable. -fn collect_rb_files(dir: &Path) -> Vec { - let mut out = Vec::new(); - walk_rb(dir, &mut out); - out -} - -fn walk_rb(dir: &Path, out: &mut Vec) { - let Ok(entries) = fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - walk_rb(&path, out); - } else if path.extension().and_then(|e| e.to_str()) == Some("rb") { - out.push(path); - } - } -} - -/// Test helper: parse one Ruby source string directly (no file I/O) -/// and return the resulting [`RubyClass`] records. Used by the D-AR-4 -/// synthetic fixture test that exercises every routed DSL name. -pub(crate) fn parse_models_from_source_for_test(src: &str) -> Vec { - let path = PathBuf::from(""); - let Some(ast) = parse_file(src, &path) else { - return Vec::new(); - }; - let mut classes = Vec::new(); - collect_classes_from_node(&ast, &mut classes); - classes -} - -/// Parse one Ruby source file. Returns the AST root or `None` on hard -/// parse failure. -fn parse_file(src: &str, path: &Path) -> Option { - let options = ParserOptions { - buffer_name: path.display().to_string(), - ..Default::default() - }; - let parser = Parser::new(src.as_bytes().to_vec(), options); - parser.do_parse().ast.map(|boxed| *boxed) -} - -/// Walk an AST node and emit a [`RubyClass`] for every `class X < Y` -/// discovered, recursing into `module ... end` wrappers and `Begin` -/// statement blocks. -fn collect_classes_from_node(node: &Node, out: &mut Vec) { - collect_classes_with_namespace(node, &[], out); -} - -/// Recursive class-discovery walk that threads the enclosing module -/// namespace through so `module Foo; class Bar < ApplicationRecord;` -/// yields `name = "Foo::Bar"` (codex P2 r3418* — module namespaces -/// were being dropped on the floor). -fn collect_classes_with_namespace(node: &Node, ns: &[String], out: &mut Vec) { - match node { - Node::Begin(b) => { - for stmt in &b.statements { - collect_classes_with_namespace(stmt, ns, out); - } - } - Node::Module(m) => { - let mod_name = const_to_string(&m.name).unwrap_or_default(); - let mut nested = ns.to_vec(); - nested.push(mod_name); - if let Some(body) = &m.body { - collect_classes_with_namespace(body, &nested, out); - } - } - Node::Class(c) => { - let local_name = const_to_string(&c.name).unwrap_or_default(); - // Qualify with the enclosing `module Foo; module Bar; class …` - // namespace stack so two same-named inner classes don't - // collide in the SPO graph. - let qualified = if ns.is_empty() { - local_name - } else { - format!("{}::{local_name}", ns.join("::")) - }; - let mut class = RubyClass { - name: qualified, - declarations: Vec::new(), - functions: Vec::new(), - }; - // STI parent is the explicit superclass when it isn't - // ApplicationRecord / ActiveRecord::Base / a synthetic root. - if let Some(super_node) = &c.superclass { - let parent = const_to_string(super_node).unwrap_or_default(); - if is_sti_parent(&parent) { - class - .declarations - .push(crate::Declaration::Sti(ruff_spo_triplet::StiInfo { - inherits_from: Some(parent), - abstract_class: false, - inheritance_column: None, - })); - } - } - if let Some(body) = &c.body { - walk_class_body(body, &mut class.declarations); - } - // D-AR-3.5: method-name + raise/reads/traverses extraction - // runs over the same class body. We pass the declarations - // (already populated above) so the body walker can filter - // `traverses_relation` candidates to declared associations. - class.functions = - extract_functions_from_body(c.body.as_deref(), &class.declarations); - out.push(class); - // A nested class inside a class body is unusual but possible - // (`class Outer; class Inner; end; end`); the inner one was - // walked as part of body — collect_classes_with_namespace - // handles it via the body's Class node arm. - if let Some(body) = &c.body { - collect_nested_classes(body, ns, out); - } - } - _ => {} - } -} - -/// Walk a class body for *nested* classes (rare in models but legal — -/// `class A; class B; end; end`). The walker treats these as siblings -/// at IR level (flat `ModelGraph::models`), matching what the SPO store -/// expects. -fn collect_nested_classes(body: &Node, ns: &[String], out: &mut Vec) { - match body { - Node::Begin(b) => { - for stmt in &b.statements { - if matches!(stmt, Node::Class(_) | Node::Module(_)) { - collect_classes_with_namespace(stmt, ns, out); - } - } - } - Node::Class(_) | Node::Module(_) => { - collect_classes_with_namespace(body, ns, out); - } - _ => {} - } -} - -/// Render a `Const` node chain to a dotted (`::`) Ruby constant string. -/// -/// `Const { scope: Some(Const{name:"A"}), name: "B" }` → `"A::B"`. The -/// chain bottoms out at a `Const` with `scope: None` or `Cbase`. -fn const_to_string(node: &Node) -> Option { - match node { - Node::Const(c) => { - let suffix = c.name.clone(); - if let Some(scope) = &c.scope { - if let Node::Cbase(_) = **scope { - Some(format!("::{suffix}")) - } else if let Some(prefix) = const_to_string(scope) { - Some(format!("{prefix}::{suffix}")) - } else { - Some(suffix) - } - } else { - Some(suffix) - } - } - _ => None, - } -} - -/// STI parent test: an explicit superclass that isn't the canonical -/// `ActiveRecord` root counts as the STI parent for [`crate::Declaration::Sti`]. -fn is_sti_parent(parent: &str) -> bool { - !matches!( - parent, - "ApplicationRecord" - | "ActiveRecord::Base" - | "::ActiveRecord::Base" - | "Object" - | "::Object" - | "" - ) -} diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/schema.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/schema.rs deleted file mode 100644 index 7425573..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/schema.rs +++ /dev/null @@ -1,500 +0,0 @@ -//! **D-AR-3.5** — the schema stratum: physical DB columns from the Rails -//! migration DSL. -//! -//! OpenProject ships no `db/schema.rb` / `db/structure.sql`; the squashed -//! baseline lives in `db/migrate/tables/*.rb` — one `Tables::X < -//! Tables::Base` class per table, whose `self.table(migration)` body is a -//! plain `create_table … do |t| … end` block of `t. :name, opts` -//! calls. That DSL is a fixed, enumerable vocabulary (22 distinct `t.*` -//! methods across OpenProject's 99 baseline files), so a line scanner in -//! the style of [`crate::functions`] extracts it without a Ruby runtime. -//! -//! # Why this stratum matters -//! -//! The WorkPackage oracle diff (op-nexgen `RESIDUAL-THREE-BUCKETS.md` §4c) -//! measured that **~90% of a hand-written Rust model struct derives from -//! the column stratum alone** (name + type + nullability), and the -//! remaining typings come from validation triples the expander already -//! ships. The class-body extraction ([`crate::extract_app_with`]) reads -//! the *method/DSL* stratum; this module supplies the missing *column* -//! stratum. Columns land as [`Field`]s (`field_type` = the DSL method -//! name verbatim, `not_null` from `null: false`), so they flow through -//! the existing `field_type` / `column_not_null` predicates with no new -//! IR shape. -//! -//! # Scope (recorded honestly, conservation-ledger style) -//! -//! - **Baseline only**: incremental migrations (`db/migrate/*.rb`, -//! `modules/*/db/migrate/*.rb`) that `add_column`/`rename_column` after -//! the squash are NOT replayed. [`SchemaReport::columns_from`] says so. -//! - Join tables and other tables with no matching AR class are counted in -//! [`SchemaReport::unmatched_tables`], never silently dropped. -//! - `t.index` / `t.foreign_key` / `t.check_constraint` / -//! `t.exclusion_constraint` lines are constraint/index facts, not -//! columns — skipped here (a later slice can lift them). - -use std::fs; -use std::path::Path; - -use ruff_spo_triplet::{Field, ModelGraph}; - -/// Conservation-ledger seed for the schema pass: what was seen, what -/// matched, what didn't — nothing drops silently. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SchemaReport { - /// Baseline table files successfully parsed. - pub tables_seen: usize, - /// Tables whose inflected model name matched a model in the graph - /// (columns merged into that model's `fields`). - pub tables_matched: usize, - /// Tables with no matching model (join tables, unported domains) — - /// named, not just counted. - pub unmatched_tables: Vec, - /// Files under `db/migrate/tables/` that could not be read or contained - /// no recognisable `create_table` block (e.g. `base.rb`, the abstract - /// helper) — named, not just counted. - pub files_skipped: Vec, - /// Provenance marker: which migration surface produced the columns. - /// Currently always `"baseline-only"` (no incremental replay). - pub columns_from: &'static str, -} - -/// One parsed baseline table: the physical columns of `table_name`, -/// plus the Rails-inflected model name they attach to. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct TableColumns { - /// The physical table name — the file stem (`work_packages`), which is - /// exactly what `Tables::Base.table_name` derives via - /// `name.demodulize.underscore`. - pub(crate) table_name: String, - /// The Rails-conventional model name (`WorkPackage`) — PascalCase - /// singular of the table name. - pub(crate) model_name: String, - /// The columns, in declaration order, as IR fields (`field_type` = - /// DSL method name verbatim; `not_null` from `null: false`). - pub(crate) fields: Vec, -} - -/// The `t.` names that declare a typed column directly. The DSL -/// method name doubles as the emitted `field_type` token (surface label — -/// consumers own the SQL/SurrealQL mapping). -const COLUMN_TYPES: &[&str] = &[ - "string", - "text", - "integer", - "bigint", - "boolean", - "datetime", - "date", - "float", - "decimal", - "jsonb", - "json", - "uuid", - "interval", - "tsvector", - "tstzrange", - "binary", - "timestamp", -]; - -/// Extract a Rails app **including the schema stratum**: everything -/// [`crate::extract_app_with`] harvests, plus the baseline DB columns -/// merged into each model's `fields`, plus a [`SchemaReport`] ledger. -/// -/// Column fields are appended to the matching model's `fields` (matched by -/// Rails inflection of the table name; existing same-name fields, if any -/// future pass creates them, are not duplicated). Tables with no matching -/// model are recorded in the report — the join-table population is real -/// and expected (`changesets_work_packages` et al. have no AR class). -#[must_use] -pub fn extract_app_with_schema(source_tree: &Path, namespace: &str) -> (ModelGraph, SchemaReport) { - let mut graph = crate::extract_app_with(source_tree, namespace); - let mut report = SchemaReport { - columns_from: "baseline-only", - ..SchemaReport::default() - }; - - for table in parse_tables_dir(source_tree, &mut report) { - report.tables_seen += 1; - if let Some(model) = graph.models.iter_mut().find(|m| m.name == table.model_name) { - report.tables_matched += 1; - for field in table.fields { - if !model.fields.iter().any(|f| f.name == field.name) { - model.fields.push(field); - } - } - } else { - report.unmatched_tables.push(table.table_name); - } - } - report.unmatched_tables.sort(); - report.files_skipped.sort(); - (graph, report) -} - -/// Parse every baseline table file under `/db/migrate/tables/`. -/// Deterministic: files are sorted before parsing (same discipline as -/// [`crate::parse`]'s walk). Unreadable / unrecognisable files land in the -/// report's `files_skipped`, not on the floor. -pub(crate) fn parse_tables_dir(source_tree: &Path, report: &mut SchemaReport) -> Vec { - let dir = source_tree.join("db/migrate/tables"); - let Ok(entries) = fs::read_dir(&dir) else { - return Vec::new(); - }; - let mut files: Vec<_> = entries - .flatten() - .map(|e| e.path()) - .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("rb")) - .collect(); - files.sort(); - - let mut tables = Vec::with_capacity(files.len()); - for path in files { - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or_default() - .to_string(); - let Ok(src) = fs::read_to_string(&path) else { - report.files_skipped.push(stem); - continue; - }; - match parse_table_source(&stem, &src) { - Some(table) => tables.push(table), - None => report.files_skipped.push(stem), - } - } - tables -} - -/// Parse one baseline table file's source. `None` when the file has no -/// `create_table` block (e.g. `base.rb`, the abstract helper class). -pub(crate) fn parse_table_source(table_name: &str, src: &str) -> Option { - let mut fields: Vec = Vec::new(); - let mut in_block = false; - let mut saw_create_table = false; - - for raw in src.lines() { - let line = raw.trim(); - if !in_block { - if line.starts_with("create_table") || line.starts_with("create_unlogged_table") { - saw_create_table = true; - in_block = true; - // Implicit primary key unless the create_table call opts out. - if !line.contains("id: false") { - fields.push(column_field("id", "bigint", true)); - } - } - continue; - } - if line == "end" { - // First `end` at block depth closes the `do |t|` block. The - // baseline files nest nothing deeper inside it. - break; - } - let Some(rest) = line.strip_prefix("t.") else { - continue; - }; - let (method, args) = match rest.split_once(char::is_whitespace) { - Some((m, a)) => (m, a.trim()), - None => (rest, ""), - }; - match method { - // Constraint / index facts, not columns. - "index" | "foreign_key" | "check_constraint" | "exclusion_constraint" => {} - // `t.timestamps precision: nil, null: true` → the pair. - "timestamps" => { - let not_null = parse_not_null(args); - fields.push(column_field("created_at", "datetime", not_null)); - fields.push(column_field("updated_at", "datetime", not_null)); - } - // `t.references :x, null: false, polymorphic: true` (alias - // `belongs_to`) → `x_id` bigint, plus `x_type` string when - // polymorphic. - "references" | "belongs_to" => { - if let Some(name) = first_symbol_arg(args) { - let not_null = parse_not_null(args); - fields.push(column_field(&format!("{name}_id"), "bigint", not_null)); - if args.contains("polymorphic: true") { - fields.push(column_field(&format!("{name}_type"), "string", not_null)); - } - } - } - // `t.column :name, :type, opts` — the explicit form. - "column" => { - let mut symbols = args.split(',').map(str::trim); - let name = symbols.next().and_then(symbol_token); - let ty = symbols.next().and_then(symbol_token); - if let (Some(name), Some(ty)) = (name, ty) { - fields.push(column_field(name, ty, parse_not_null(args))); - } - } - // `t. :name, opts` — the direct typed forms. - m if COLUMN_TYPES.contains(&m) => { - if let Some(name) = first_symbol_arg(args) { - fields.push(column_field(name, m, parse_not_null(args))); - } - } - // Unknown t.* method: not a column declaration we recognise. - // The closed COLUMN_TYPES list + this arm make additions an - // explicit act (same discipline as the Predicate count-lock). - _ => {} - } - } - - if !saw_create_table { - return None; - } - Some(TableColumns { - table_name: table_name.to_string(), - model_name: model_name_for_table(table_name), - fields, - }) -} - -/// Build one column [`Field`]: `field_type` carries the DSL method name -/// verbatim; `not_null` only when the DSL says `null: false`. -fn column_field(name: &str, dsl_type: &str, not_null: bool) -> Field { - Field { - name: name.to_string(), - field_type: Some(dsl_type.to_string()), - not_null: if not_null { Some(true) } else { None }, - ..Field::default() - } -} - -/// `null: false` anywhere in the arg list → NOT NULL. Rails' default for -/// columns is nullable, so absence (or explicit `null: true`) is `false`. -fn parse_not_null(args: &str) -> bool { - args.contains("null: false") -} - -/// The first `:symbol` argument, e.g. `":subject, default: …"` → `subject`. -fn first_symbol_arg(args: &str) -> Option<&str> { - args.split(',').next().and_then(symbol_token) -} - -/// `":name"` → `name` (with surrounding whitespace tolerated). -fn symbol_token(part: &str) -> Option<&str> { - part.trim().strip_prefix(':').map(|s| s.trim_end()) -} - -/// Rails inflection, table → model: snake_case plural → PascalCase -/// singular (`work_packages` → `WorkPackage`). Only the last segment is -/// singularised. The rule chain covers the OpenProject baseline corpus; -/// genuinely irregular names belong in `IRREGULAR`, and a miss lands the -/// table in `unmatched_tables` (visible), never on a wrong model. -pub(crate) fn model_name_for_table(table: &str) -> String { - /// Table names whose singular is not rule-derivable. - const IRREGULAR: &[(&str, &str)] = &[("news", "news"), ("meeting_agenda_item_series", "meeting_agenda_item_series")]; - - let segments: Vec<&str> = table.split('_').collect(); - let mut out = String::new(); - let last = segments.len().saturating_sub(1); - for (i, seg) in segments.iter().enumerate() { - let word = if i == last { - singularize(seg, table, IRREGULAR) - } else { - (*seg).to_string() - }; - let mut chars = word.chars(); - if let Some(first) = chars.next() { - out.push(first.to_ascii_uppercase()); - out.push_str(chars.as_str()); - } - } - out -} - -/// Singularise one snake_case segment (the table's final word). -fn singularize(seg: &str, full_table: &str, irregular: &[(&str, &str)]) -> String { - if let Some((_, singular)) = irregular.iter().find(|(t, _)| *t == full_table) { - return (*singular).to_string(); - } - if let Some(stem) = seg.strip_suffix("ies") { - return format!("{stem}y"); - } - for es_suffix in ["sses", "shes", "ches", "xes", "zes", "uses"] { - if seg.ends_with(es_suffix) { - return seg[..seg.len() - 2].to_string(); - } - } - seg.strip_suffix('s').unwrap_or(seg).to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &str = r#" -class Tables::WorkPackages < Tables::Base - def self.table(migration) - create_table migration do |t| - t.references :type, null: false, index: true, foreign_key: { on_delete: :cascade } - t.string :subject, default: "", null: false - t.text :description - t.integer :done_ratio, default: nil, null: true - t.timestamps precision: nil, null: true, index: true - t.belongs_to :responsible - t.boolean :schedule_manually, default: true, null: false - t.references :reactable, polymorphic: true, null: false - t.column :builtin, :boolean, default: false, null: false - - t.index %i[project_id updated_at] - t.check_constraint "due_date >= start_date", name: "x" - end - end -end -"#; - - #[test] - fn parses_the_dsl_forms() { - let t = parse_table_source("work_packages", SAMPLE).expect("create_table block"); - assert_eq!(t.model_name, "WorkPackage"); - let names: Vec<&str> = t.fields.iter().map(|f| f.name.as_str()).collect(); - assert_eq!( - names, - [ - "id", - "type_id", - "subject", - "description", - "done_ratio", - "created_at", - "updated_at", - "responsible_id", - "schedule_manually", - "reactable_id", - "reactable_type", - "builtin", - ] - ); - let by_name = |n: &str| t.fields.iter().find(|f| f.name == n).unwrap(); - // Implicit PK. - assert_eq!(by_name("id").field_type.as_deref(), Some("bigint")); - assert_eq!(by_name("id").not_null, Some(true)); - // references → _id bigint, null: false honoured. - assert_eq!(by_name("type_id").field_type.as_deref(), Some("bigint")); - assert_eq!(by_name("type_id").not_null, Some(true)); - // Plain nullable column: no positive fact. - assert_eq!(by_name("description").field_type.as_deref(), Some("text")); - assert_eq!(by_name("description").not_null, None); - // Explicit null: true stays absent (nullable is the default). - assert_eq!(by_name("done_ratio").not_null, None); - // timestamps pair, honouring null: true. - assert_eq!(by_name("created_at").field_type.as_deref(), Some("datetime")); - assert_eq!(by_name("created_at").not_null, None); - // belongs_to alias. - assert_eq!(by_name("responsible_id").field_type.as_deref(), Some("bigint")); - // Polymorphic pair — the PolyRef substrate declaring itself. - assert_eq!(by_name("reactable_id").not_null, Some(true)); - assert_eq!(by_name("reactable_type").field_type.as_deref(), Some("string")); - // t.column explicit form. - assert_eq!(by_name("builtin").field_type.as_deref(), Some("boolean")); - assert_eq!(by_name("builtin").not_null, Some(true)); - } - - #[test] - fn id_false_suppresses_the_implicit_pk() { - let src = "create_table migration, id: false do |t|\n t.bigint :a_id\nend\n"; - let t = parse_table_source("a_b_joins", src).expect("block"); - assert_eq!(t.fields.len(), 1); - assert_eq!(t.fields[0].name, "a_id"); - } - - #[test] - fn base_helper_file_is_not_a_table() { - assert!(parse_table_source("base", "class Tables::Base\nend\n").is_none()); - } - - #[test] - fn inflection_covers_the_corpus_shapes() { - for (table, model) in [ - ("work_packages", "WorkPackage"), - ("statuses", "Status"), - ("categories", "Category"), - ("queries", "Query"), - ("changes", "Change"), - ("changesets", "Changeset"), - ("news", "News"), - ("attachments", "Attachment"), - ("custom_fields", "CustomField"), - ("issue_priorities", "IssuePriority"), - ] { - assert_eq!(model_name_for_table(table), model, "{table}"); - } - } - - /// Corpus gate (same pattern as the crate's D-AR-4 gate): only runs - /// with `OPENPROJECT_PATH` set. Pins the WorkPackage baseline column - /// set — the oracle-diff ground truth. - #[test] - fn openproject_corpus_schema_gate() { - let Ok(root) = std::env::var("OPENPROJECT_PATH") else { - eprintln!("skipping: OPENPROJECT_PATH not set"); - return; - }; - let (graph, report) = extract_app_with_schema(Path::new(&root), "openproject"); - assert_eq!(report.columns_from, "baseline-only"); - assert!(report.tables_seen >= 90, "expected ~99 baseline tables, saw {}", report.tables_seen); - assert!(report.tables_matched >= 50, "matched only {}", report.tables_matched); - eprintln!( - "D-AR-3.5 schema gate: {} tables seen, {} matched, {} unmatched, {} files skipped", - report.tables_seen, - report.tables_matched, - report.unmatched_tables.len(), - report.files_skipped.len() - ); - - let wp = graph - .models - .iter() - .find(|m| m.name == "WorkPackage") - .expect("WorkPackage model"); - let cols: Vec<&str> = wp - .fields - .iter() - .filter(|f| f.field_type.is_some()) - .map(|f| f.name.as_str()) - .collect(); - assert_eq!(cols.len(), 27, "baseline WorkPackage columns: {cols:?}"); - for expected in [ - "id", - "type_id", - "project_id", - "subject", - "description", - "due_date", - "category_id", - "status_id", - "assigned_to_id", - "priority_id", - "version_id", - "author_id", - "lock_version", - "done_ratio", - "estimated_hours", - "created_at", - "updated_at", - "start_date", - "responsible_id", - "derived_estimated_hours", - "schedule_manually", - "parent_id", - "duration", - "ignore_non_working_days", - "derived_remaining_hours", - "derived_done_ratio", - "project_phase_id", - ] { - assert!(cols.contains(&expected), "missing column {expected}"); - } - let by_name = |n: &str| wp.fields.iter().find(|f| f.name == n).unwrap(); - assert_eq!(by_name("subject").field_type.as_deref(), Some("string")); - assert_eq!(by_name("subject").not_null, Some(true)); - assert_eq!(by_name("done_ratio").field_type.as_deref(), Some("integer")); - assert_eq!(by_name("done_ratio").not_null, None, "unset ≠ 0% — the oracle-diff bug"); - assert_eq!(by_name("schedule_manually").not_null, Some(true)); - } -} diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/walk.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/walk.rs deleted file mode 100644 index 40dc21f..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_ruby_spo/src/walk.rs +++ /dev/null @@ -1,677 +0,0 @@ -//! Class-body walker — routes top-level `Send` calls in a class body to -//! the right [`crate::Declaration`] variant. -//! -//! This is the routing core of D-AR-3: the 67 emit categories measured -//! on the `OpenProject` corpus map to 13 [`Declaration`] variants here. -//! Method-name dispatch is exhaustive over the closed-vocab table -//! (`.claude/plans/openproject-ar-shape-extraction-v1.md` §2 on -//! lance-graph), so the D-AR-4 coverage test fails loudly on any new -//! name not yet routed. -//! -//! The walker is intentionally a single dispatch function over -//! `&str method_name` — no trait, no visitor pattern, no -//! `match_node!` macros (per the prior-art-savant Round-1 verdict: -//! additions-only, free fn over the IR, no new trait surface). - -use lib_ruby_parser::Node; -use ruff_spo_triplet::{ - ActsAs, AssocDecl, AssocKind, AttrDecl, AttrKind, Callback, ConcernKind, ConcernRef, - Delegation, DslCall, DynMethod, GemDsl, GemKind, ScopeDecl, ScopeKind, UsingRef, Validation, - ValidationKind, -}; - -use crate::Declaration; - -/// Walk a `Class.body` node and append every recognised DSL call as a -/// [`Declaration`]. -pub(crate) fn walk_class_body(body: &Node, out: &mut Vec) { - match body { - Node::Begin(b) => { - for stmt in &b.statements { - walk_statement(stmt, out); - } - } - single => walk_statement(single, out), - } -} - -/// Process one class-body statement. -fn walk_statement(node: &Node, out: &mut Vec) { - match node { - Node::Send(s) if s.recv.is_none() => route_send(&s.method_name, &s.args, out), - Node::Block(blk) => walk_block(blk, out), - // Ruby keyword `alias new orig` parses as Node::Alias, NOT as a - // Send; pick up method-alias facts that would otherwise be lost - // (codex P2 PR #6 r3418*). - Node::Alias(a) => { - let new_name = alias_target_name(&a.to); - let orig_name = alias_target_name(&a.from); - out.push(Declaration::Attribute(ruff_spo_triplet::AttrDecl { - kind: ruff_spo_triplet::AttrKind::Alias, - name: format!("{new_name}={orig_name}"), - options: Vec::new(), - })); - } - // Visibility modifiers / `class << self`-wrapped sends / def blocks - // are not DSL declarations and don't produce Declarations. - _ => {} - } -} - -/// Render the `from` / `to` of a `Node::Alias`. lib-ruby-parser -/// expresses both as `Node::Sym` for the keyword form. -fn alias_target_name(node: &Node) -> String { - match node { - Node::Sym(s) => s.name.to_string_lossy(), - _ => render_node(node), - } -} - -/// `something do ... end` — the call inside the block may itself be a -/// DSL declaration (e.g. `scope :foo, -> { … }` parses as a Send whose -/// last arg is a Block; `class_methods do ... end` parses as a Block -/// whose call is `class_methods`). -fn walk_block(blk: &lib_ruby_parser::nodes::Block, out: &mut Vec) { - let Node::Send(s) = &*blk.call else { - return; - }; - if s.recv.is_some() { - return; - } - let body_ref = format_loc(&blk_body_loc(blk)); - match s.method_name.as_str() { - "class_methods" => out.push(Declaration::Concern(ConcernRef { - kind: ConcernKind::ClassMethodsBlock, - module: String::new(), - body_ref: Some(body_ref), - })), - "included" => out.push(Declaration::Concern(ConcernRef { - kind: ConcernKind::IncludedBlock, - module: String::new(), - body_ref: Some(body_ref), - })), - "scope" => { - if let Some(name) = s.args.first().and_then(sym_string) { - out.push(Declaration::Scope(ScopeDecl { - kind: ScopeKind::Scope, - name, - body_ref, - })); - } - } - "default_scope" => out.push(Declaration::Scope(ScopeDecl { - kind: ScopeKind::DefaultScope, - name: String::new(), - body_ref, - })), - "validate" => { - // `validate { … }` — block-form custom validator. - out.push(Declaration::Validation(Validation { - kind: ValidationKind::Validate, - target: "".to_string(), - options: Vec::new(), - })); - } - // Rails grouping blocks — recurse INTO the block body so nested - // declarations (`with_options presence: true do; validates :name; end`) - // are not silently dropped (codex P2 PR #6 r3418*). Limited to - // known grouping idioms to avoid double-counting from arbitrary - // blocks (e.g. `5.times do; validates :foo; end` would NOT count - // because that's runtime-only). - "with_options" => { - if let Some(body) = blk.body.as_ref() { - walk_class_body(body, out); - } - // Also record the wrapper call itself so the catch-all - // coverage assertion still sees `with_options` somewhere. - route_send(s.method_name.as_str(), &s.args, out); - } - other => { - // Generic block: route the call as if it were a bare Send, - // then drop the body. Conservative — keeps the call counted - // for coverage without inventing a Declaration shape for - // arbitrary block DSLs. - route_send(other, &s.args, out); - } - } -} - -/// Locate a Block's body range (best-effort — the parser may inline a -/// single-statement body without an explicit Begin). -fn blk_body_loc(blk: &lib_ruby_parser::nodes::Block) -> lib_ruby_parser::Loc { - blk.body - .as_ref() - .map(|b| match b.as_ref() { - Node::Begin(b) => b.expression_l, - other => node_loc(other), - }) - .unwrap_or(blk.expression_l) -} - -/// Approximate a node's expression location by enum tag — works for -/// the node types the walker actually inspects. -fn node_loc(node: &Node) -> lib_ruby_parser::Loc { - match node { - Node::Send(s) => s.expression_l, - Node::Block(b) => b.expression_l, - Node::Begin(b) => b.expression_l, - Node::Def(d) => d.expression_l, - _ => lib_ruby_parser::Loc { begin: 0, end: 0 }, - } -} - -/// Format a Loc as `".."` for the `body_ref` slot. Bytes, -/// not lines — line-mapping would require the `DecodedInput` which we -/// drop after parsing. -fn format_loc(loc: &lib_ruby_parser::Loc) -> String { - format!("{}..{}", loc.begin, loc.end) -} - -/// Extract a `body_ref` from a `Node::Lambda` arg (i.e. `-> { … }`). -/// Returns `None` for non-lambda nodes. -fn lambda_loc(node: &Node) -> Option { - match node { - Node::Lambda(l) => Some(format_loc(&l.expression_l)), - _ => None, - } -} - -/// Route a bare class-body `Send` call (no receiver) to a Declaration -/// variant. The 78-name closed vocab maps onto this match. -/// -/// **Iron-rule lock:** every non-scope-marker class-body method -/// observed on the `OpenProject` corpus MUST appear in either an -/// explicit arm or the `has_dsl_call` catch-all. The D-AR-4 coverage -/// test fails loudly if a real corpus call slips through unmatched. -#[allow(clippy::too_many_lines)] // exhaustive 78-name routing — splitting hurts readability -fn route_send(name: &str, args: &[Node], out: &mut Vec) { - match name { - // ───── Associations (5) ───── - "belongs_to" => emit_assoc(AssocKind::BelongsTo, args, out), - "has_many" => emit_assoc(AssocKind::HasMany, args, out), - "has_one" => emit_assoc(AssocKind::HasOne, args, out), - "has_and_belongs_to_many" => emit_assoc(AssocKind::HasAndBelongsToMany, args, out), - "accepts_nested_attributes_for" => { - emit_assoc(AssocKind::AcceptsNestedAttributesFor, args, out); - } - - // ───── Validations (5) ───── - "validates" => emit_validation(ValidationKind::Validates, args, out), - "validate" => emit_validation(ValidationKind::Validate, args, out), - "normalizes" => emit_validation(ValidationKind::Normalizes, args, out), - "validates_associated" => emit_validation(ValidationKind::ValidatesAssociated, args, out), - "validates_each" => emit_validation(ValidationKind::ValidatesEach, args, out), - - // ───── Callbacks (13 phases) ───── - n if is_callback_phase(n) => emit_callback(n, args, out), - - // ───── Concerns (3 non-block; block forms handled in walk_block) ───── - "include" => emit_concern(ConcernKind::Include, args, out), - "extend" => emit_concern(ConcernKind::Extend, args, out), - "prepend" => emit_concern(ConcernKind::Prepend, args, out), - - // ───── Attributes (13) ───── - "attribute" => emit_attr(AttrKind::Attribute, args, out), - "attr_accessor" => emit_attr(AttrKind::AttrAccessor, args, out), - "attr_reader" => emit_attr(AttrKind::AttrReader, args, out), - "attr_readonly" => emit_attr(AttrKind::AttrReadonly, args, out), - "alias_attribute" => emit_attr(AttrKind::AliasAttribute, args, out), - "alias_method" => emit_attr(AttrKind::AliasMethod, args, out), - // Note: `alias new orig` is the Ruby keyword form. lib-ruby-parser - // exposes it as `Node::Alias` (a separate variant), NOT as a Send, - // so it lands in `walk_statement`'s catch-all. Handled below. - "undef_method" => emit_attr(AttrKind::UndefMethod, args, out), - "serialize" => emit_attr(AttrKind::Serialize, args, out), - "enum" => emit_attr(AttrKind::Enum, args, out), - "store_attribute" => emit_attr(AttrKind::StoreAttribute, args, out), - "store_accessor" => emit_attr(AttrKind::StoreAccessor, args, out), - "define_attribute_method" => emit_attr(AttrKind::DefineAttributeMethod, args, out), - - // ───── Delegation ───── - "delegate" => emit_delegation(args, out), - - // ───── Scope (3 forms) ───── - // `scope :name, ->{ … }` / `scope :name do … end` — the lambda - // and do-block forms produce different AST shapes; the lambda - // arrives as a Send arg (handled here), the do-block as a Block - // (handled by walk_block). - "scope" => { - if let Some(name) = args.first().and_then(sym_string) { - out.push(Declaration::Scope(ScopeDecl { - kind: ScopeKind::Scope, - name, - body_ref: args - .iter() - .skip(1) - .find_map(lambda_loc) - .unwrap_or_else(|| "".to_string()), - })); - } - } - "default_scope" => { - let body_ref = args - .iter() - .find_map(lambda_loc) - .unwrap_or_else(|| "".to_string()); - out.push(Declaration::Scope(ScopeDecl { - kind: ScopeKind::DefaultScope, - name: String::new(), - body_ref, - })); - } - "scopes" => emit_scopes_plural(args, out), - - // ───── acts_as_* family (10 + open-ended) ───── - n if n.starts_with("acts_as_") => emit_acts_as(n, args, out), - - // ───── OpenProject custom registrations: promoted ───── - "register_journal_formatter" | "register_journal_formatted_fields" - | "register_query" | "activity_provider_for" | "deprecated_alias" - | "associated_to_ask_before_destruction" | "has_details_table" => { - out.push(Declaration::DslCall(DslCall { - name: name.to_string(), - args: format_args(args), - })); - } - - // ───── Third-party gem DSL (5) ───── - "mount_uploader" => out.push(Declaration::GemDsl(GemDsl { - gem: GemKind::MountUploader, - args: format_args(args), - })), - "has_paper_trail" => out.push(Declaration::GemDsl(GemDsl { - gem: GemKind::HasPaperTrail, - args: format_args(args), - })), - "has_closure_tree" => out.push(Declaration::GemDsl(GemDsl { - gem: GemKind::HasClosureTree, - args: format_args(args), - })), - "counter_culture" => out.push(Declaration::GemDsl(GemDsl { - gem: GemKind::CounterCulture, - args: format_args(args), - })), - "auto_strip_attributes" => out.push(Declaration::GemDsl(GemDsl { - gem: GemKind::AutoStripAttributes, - args: format_args(args), - })), - - // ───── Metaprogramming ───── - "define_method" => { - if let Some(name_expr) = args.first() { - out.push(Declaration::DynamicMethod(DynMethod { - name_expr: render_node(name_expr), - body_ref: format_loc(&node_loc(name_expr)), - })); - } - } - - // ───── Refinements ───── - "using" => { - if let Some(refinement) = args.first().and_then(const_string) { - out.push(Declaration::Using(UsingRef { - refinement_module: refinement, - })); - } - } - - // ───── Scope markers — consume silently (not emitted) ───── - "private" | "protected" | "public" | "private_class_method" - | "private_constant" | "class_attribute" | "module_function" => {} - - // ───── Unknown DSL — catch-all so D-AR-4 coverage stays 100 % ───── - // The OpenProject §2 closed-vocab table lists every name observed - // on the corpus; this arm is the safety net for any name not yet - // promoted to a discriminated predicate (and for new OP DSL - // calls that arrive after the §2 census). - _ => out.push(Declaration::DslCall(DslCall { - name: name.to_string(), - args: format_args(args), - })), - } -} - -// ───────────────────────────────────────────────────────────────────────── -// Per-category emitters -// ───────────────────────────────────────────────────────────────────────── - -fn emit_assoc(kind: AssocKind, args: &[Node], out: &mut Vec) { - let Some(name) = args.first().and_then(sym_string) else { - return; - }; - // Scan ALL args for the first Hash to capture options (codex P2 - // PR #6 r3418*). The scoped-association form - // `has_many :items, -> { active }, dependent: :destroy` puts the - // options hash at args[2], not args[1] (the lambda sits between - // the name and the options). - let options = args - .iter() - .skip(1) - .find_map(as_hash_options) - .unwrap_or_default(); - out.push(Declaration::Association(AssocDecl { - kind, - name, - options, - })); -} - -fn emit_validation(kind: ValidationKind, args: &[Node], out: &mut Vec) { - // `validate :method_name` / `validates :attr, ...` — first arg is - // either a sym (attr/method name) or another shape (block form). - let target = args.first().and_then(sym_string).unwrap_or_else(|| { - if !args.is_empty() { - "".to_string() - } else { - "".to_string() - } - }); - let options = args - .iter().find_map(as_hash_options) - .unwrap_or_default(); - out.push(Declaration::Validation(Validation { - kind, - target, - options, - })); -} - -fn emit_callback(phase: &str, args: &[Node], out: &mut Vec) { - let target = args - .first() - .and_then(sym_string) - .unwrap_or_else(|| "".to_string()); - let options = args - .iter().find_map(as_hash_options) - .unwrap_or_default(); - out.push(Declaration::Callback(Callback { - phase: phase.to_string(), - target, - options, - })); -} - -fn emit_concern(kind: ConcernKind, args: &[Node], out: &mut Vec) { - for arg in args { - if let Some(module) = const_string(arg) { - out.push(Declaration::Concern(ConcernRef { - kind, - module, - body_ref: None, - })); - } - } -} - -fn emit_attr(kind: AttrKind, args: &[Node], out: &mut Vec) { - // Two-arg alias forms (`alias_attribute :new, :orig`) → one decl with - // "new=orig" name; everything else takes one declaration per leading - // symbol arg. - if matches!( - kind, - AttrKind::AliasAttribute | AttrKind::AliasMethod - ) { - if args.len() >= 2 { - let new_n = sym_string(&args[0]).unwrap_or_default(); - let orig = sym_string(&args[1]).unwrap_or_default(); - out.push(Declaration::Attribute(AttrDecl { - kind, - name: format!("{new_n}={orig}"), - options: Vec::new(), - })); - } - return; - } - // The arity of "which positional symbol arguments are attribute - // names" varies by macro (codex P2 PR #6 r3418*): - // `attribute :age, :integer` — 1 attr (skip type at args[1]) - // `attr_accessor :a, :b, :c` — N attrs - // `serialize :data, JSON` — 1 attr (skip class) - // `enum :status, { active: 0 }` — 1 attr (skip Hash) - // `store_attribute :store, :attr, :int` — 1 attr at args[1] (skip store + type) - // `store_accessor :store, :a, :b, :c` — N attrs from args[1..] (skip store) - // `define_attribute_method :attr` — 1 attr - // `undef_method :foo` — 1 attr - let (skip, take) = attr_arg_window(kind); - let mut options = args - .iter() - .skip(skip.saturating_add(take)) - .find_map(as_hash_options) - .unwrap_or_default(); - // D-AR-5.2: pull the Rails static type annotation out of the - // positional sym that sits right after the attribute name (or - // after the store key + attr name for store_attribute), and store - // it as `options[("type", "")]` so the expander can - // emit a `field_type` triple. `attribute :age, :integer` puts the - // type at the slot right after the take-window; `serialize :data, - // JSON` and `store_attribute :store, :attr, :integer` follow the - // same pattern (single-attr-then-type macros only). - if attr_has_positional_type(kind) { - let type_idx = skip.saturating_add(take); - if let Some(arg) = args.get(type_idx) { - if let Some(t) = sym_string(arg) { - options.push(("type".to_string(), t)); - } - } - } - for arg in args.iter().skip(skip).take(take) { - let Some(name) = sym_string(arg) else { continue }; - out.push(Declaration::Attribute(AttrDecl { - kind, - name, - options: options.clone(), - })); - } -} - -/// `attribute :age, :integer` and `store_attribute :store, :attr, :type` -/// carry the Rails static type as a positional Sym AFTER the attribute -/// name. Multi-attr macros (`attr_accessor :a, :b`) and meta-attr -/// macros (`Serialize`, `Enum`) have no such slot. -fn attr_has_positional_type(kind: AttrKind) -> bool { - matches!(kind, AttrKind::Attribute | AttrKind::StoreAttribute) -} - -/// `(skip, take)` window into the positional args that carry attribute -/// names for each [`AttrKind`]. Args outside this window are type / -/// class / store-key / hash metadata and MUST NOT be treated as -/// attribute names. -/// -/// `take == usize::MAX` means "all remaining positional symbol args" -/// (e.g. `attr_accessor :a, :b, :c`). -fn attr_arg_window(kind: AttrKind) -> (usize, usize) { - match kind { - // Single-attr macros — name at args[0], type/class/hash at args[1+]. - AttrKind::Attribute - | AttrKind::Serialize - | AttrKind::Enum - | AttrKind::DefineAttributeMethod - | AttrKind::UndefMethod - | AttrKind::AttrReadonly => (0, 1), - // Multi-attr macros — every positional symbol is an attribute name. - AttrKind::AttrAccessor | AttrKind::AttrReader => (0, usize::MAX), - // Store-style: args[0] is the store key (NOT an attribute); - // args[1+] is/are the attribute name(s). - // `store_attribute :store, :attr, :type` → 1 attr at args[1]. - // `store_accessor :store, :a, :b, :c` → N attrs from args[1..]. - AttrKind::StoreAttribute => (1, 1), - AttrKind::StoreAccessor => (1, usize::MAX), - // Aliases are handled in the early-return above. - AttrKind::AliasAttribute | AttrKind::AliasMethod | AttrKind::Alias => (0, 0), - } -} - -fn emit_delegation(args: &[Node], out: &mut Vec) { - let mut methods = Vec::new(); - let mut to = String::new(); - let mut options = Vec::new(); - for arg in args { - if let Some(sym) = sym_string(arg) { - methods.push(sym); - } else if let Some(opts) = as_hash_options(arg) { - for (k, v) in &opts { - if k == "to" { - to = v.trim_start_matches(':').to_string(); - } else { - options.push((k.clone(), v.clone())); - } - } - } - } - if !methods.is_empty() { - out.push(Declaration::Delegation(Delegation { - methods, - to, - options, - })); - } -} - -fn emit_scopes_plural(args: &[Node], out: &mut Vec) { - // OP plural form `scopes :a, :b, :c` — one ScopeDecl per name, - // body_ref placeholder (no per-scope lambda in the plural form). - for arg in args { - if let Some(name) = sym_string(arg) { - out.push(Declaration::Scope(ScopeDecl { - kind: ScopeKind::Scopes, - name, - body_ref: "".to_string(), - })); - } - } -} - -fn emit_acts_as(name: &str, args: &[Node], out: &mut Vec) { - let variant = name.strip_prefix("acts_as_").unwrap_or(name).to_string(); - let options = args - .iter().find_map(as_hash_options) - .unwrap_or_default(); - out.push(Declaration::ActsAs(ActsAs { variant, options })); -} - -// ───────────────────────────────────────────────────────────────────────── -// Arg shape helpers -// ───────────────────────────────────────────────────────────────────────── - -/// Extract a `:symbol` literal as a String. Returns `None` for non-Sym -/// nodes. -fn sym_string(node: &Node) -> Option { - match node { - Node::Sym(s) => Some(s.name.to_string_lossy()), - _ => None, - } -} - -/// Render a `Const` reference as a dotted Ruby constant path. -fn const_string(node: &Node) -> Option { - match node { - Node::Const(c) => { - let suffix = c.name.clone(); - if let Some(scope) = &c.scope { - if let Node::Cbase(_) = **scope { - Some(format!("::{suffix}")) - } else if let Some(prefix) = const_string(scope) { - Some(format!("{prefix}::{suffix}")) - } else { - Some(suffix) - } - } else { - Some(suffix) - } - } - _ => None, - } -} - -/// Best-effort string render of a Node for arg-blob slots. Lossy but -/// stable enough for queryability. -fn render_node(node: &Node) -> String { - match node { - Node::Sym(s) => format!(":{}", s.name.to_string_lossy()), - Node::Str(s) => format!("\"{}\"", s.value.to_string_lossy()), - Node::Int(i) => i.value.clone(), - Node::Const(_) => const_string(node).unwrap_or_default(), - Node::True(_) => "true".to_string(), - Node::False(_) => "false".to_string(), - Node::Nil(_) => "nil".to_string(), - Node::Array(a) => { - let elems = a.elements.iter().map(render_node).collect::>().join(","); - format!("[{elems}]") - } - Node::Hash(h) => format_hash_inline(h), - _ => "".to_string(), - } -} - -/// Try to interpret a Node as a Hash of `key: value` pairs and render -/// each pair as `(key, value)` for the `options` slot. Accepts both -/// `Hash { pairs }` (literal `{k: v}` braces) and `Kwargs { pairs }` -/// (trailing `k: v, k2: v2` keyword arguments — common in Rails macro -/// calls like `has_many :x, dependent: :destroy`). -fn as_hash_options(node: &Node) -> Option> { - let pairs = match node { - Node::Hash(h) => &h.pairs, - Node::Kwargs(k) => &k.pairs, - _ => return None, - }; - let mut out = Vec::new(); - for pair_node in pairs { - let Node::Pair(p) = pair_node else { continue }; - let key = match p.key.as_ref() { - Node::Sym(s) => s.name.to_string_lossy(), - Node::Str(s) => s.value.to_string_lossy(), - other => render_node(other), - }; - let value = render_node(&p.value); - out.push((key, value)); - } - Some(out) -} - -/// Render a Hash node inline as `{k: v, k2: v2}` for the args blob. -fn format_hash_inline(h: &lib_ruby_parser::nodes::Hash) -> String { - let mut parts = Vec::new(); - for pair_node in &h.pairs { - let Node::Pair(p) = pair_node else { continue }; - let key = render_node(&p.key); - let value = render_node(&p.value); - parts.push(format!("{key}: {value}")); - } - format!("{{{}}}", parts.join(", ")) -} - -/// Render the full arg list as one verbatim string for catch-all -/// `has_dsl_call` and `GemDsl` slots. -fn format_args(args: &[Node]) -> String { - args.iter().map(render_node).collect::>().join(", ") -} - -/// The 13 Rails callback phases observed on the `OpenProject` corpus. -/// Pattern is `before_*` / `after_*` / `around_*` — guarded by an -/// allow-list because Rails recognises specific suffixes and an -/// arbitrary `before_foo` would otherwise route here too. -fn is_callback_phase(name: &str) -> bool { - matches!( - name, - "before_save" - | "before_destroy" - | "before_create" - | "before_validation" - | "before_update" - | "after_save" - | "after_destroy" - | "after_create" - | "after_update" - | "after_commit" - | "after_validation" - | "after_initialize" - | "after_destroy_commit" - | "after_create_commit" - | "after_update_commit" - | "after_save_commit" - | "around_destroy" - | "around_save" - | "around_create" - | "around_update" - ) -} diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/Cargo.toml b/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/Cargo.toml deleted file mode 100644 index 60d1299..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "ruff_spo_triplet" -version = "0.1.0" -publish = false -edition = "2024" -rust-version = "1.86" -description = "Language-agnostic SPO (Subject-Predicate-Object) triplet expansion. Turns a neutral ModelGraph IR — entities, properties, functions, and their compute/guard/traversal edges — into deterministic NARS-weighted RDF-shape triples (ndjson). The Python/Odoo frontend (ruff_python_dto_check) and a future Ruby/Rails frontend (OpenProject) both fill the same IR and call the same expander, so the downstream SPO graph is identical regardless of source language." -license = "MIT" - -[lib] -name = "ruff_spo_triplet" - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } - -[lints] -workspace = true diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/SPO_TRIPLET_EXTRACTION.md b/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/SPO_TRIPLET_EXTRACTION.md deleted file mode 100644 index 363c26f..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/SPO_TRIPLET_EXTRACTION.md +++ /dev/null @@ -1,252 +0,0 @@ -# SPO Triplet Extraction — methodology & cross-language reuse guide - -> **Audience:** anyone wiring a new source-language frontend (e.g. the -> OpenProject Ruby/Rails extraction) onto the shared SPO triplet core. -> -> **TL;DR:** parse your AST → fill a `ModelGraph` → call `expand()` → -> write ndjson. The triple vocabulary, truth calibration, and IRI shape -> are fixed in `ruff_spo_triplet`; you only write the AST→IR step. - ---- - -## 1. What this is and why it exists - -Business logic in an ORM-backed app (Odoo, Rails, Django, …) is a graph: -**entities** own **fields** and **methods**; methods **compute** fields, -**depend on** other fields, **read** fields, **raise** errors, and -**traverse** relations. That graph is the same shape regardless of the -host language — only the syntax that expresses it differs. - -`ruff_spo_triplet` captures that shape once as a closed set of **SPO -triples** (Subject–Predicate–Object) with NARS `(frequency, confidence)` -truth values. The Odoo (Python) extraction and the OpenProject -(Ruby/Rails) extraction both emit **byte-identical** triple shapes, so a -single downstream consumer (`lance_graph`'s SPO store, the Foundry-shape -`action_emitter`, the `link_chain` splitter) works on either without -modification. - -``` - Python AST ─┐ - ├─► ModelGraph (IR) ─► expand() ─► Vec ─► ndjson ─► SPO store - Ruby AST ──┘ ▲ ▲ ▲ - language-specific shared core shared core - (you write this) (this crate) (this crate) -``` - -The reuse seam is the `ModelGraph` IR. Everything below the IR is shared; -everything above it is the per-language frontend. - ---- - -## 2. The triple schema (closed vocabulary) - -Nine triple forms over seven predicates. `ns` is the namespace prefix you -choose for the source app (`odoo`, `openproject`, …). - -| predicate | subject | object | provenance | meaning | -| --- | --- | --- | --- | --- | -| `rdf:type` | `ns:model` | `ogit:ObjectType` | Structural | this name is an entity | -| `rdf:type` | `ns:model.field` | `ogit:Property` | Structural | this name is a field | -| `rdf:type` | `ns:model.fn` | `ogit:Function` | Structural | this name is a method | -| `has_function` | `ns:model` | `ns:model.fn` | Structural | entity owns method | -| `emitted_by` | `ns:model.field` | `ns:model.fn` | Authoritative | method writes field | -| `depends_on` | `ns:model.field` | `ns:model.` | Authoritative | field's declared compute deps | -| `reads_field` | `ns:model.fn` | `ns:model.field` | Inferred | method body reads field | -| `raises` | `ns:model.fn` | `exc:` | Authoritative | method raises error | -| `traverses_relation` | `ns:model.fn` | `ns:model.` | Inferred | method walks relation | - -**IRI shape.** Subjects and objects are `":."`. The -single dot separates model from member; dotted *dependency paths* -(`line_ids.balance`) are emitted **verbatim** under the model IRI -(`odoo:account_move.line_ids.balance`) and split into per-hop link -triples later by the downstream `link_chain` splitter — the extractor -stays source-faithful and does no path resolution. - -**`ogit:` is the canonical OGIT vocabulary** (`http://www.purl.org/ogit/`), -not a project-local namespace. Don't invent `https://…/ObjectType`. - -### Provenance → truth (the NARS calibration) - -| tier | `(f, c)` | when | -| --- | --- | --- | -| `Structural` | `(1.0, 1.0)` | true by construction (a name *is* a model/field/method; ownership) | -| `Authoritative` | `(0.95, 0.90)`| declared or directly observed in body (`@api.depends`, a `raise`, the field a compute assigns) | -| `Inferred` | `(0.85, 0.75)`| heuristic from body shape (an attribute read, a loop-target relation) | - -The downstream store gates queries by NARS *expectation*, so a strict -query can drop `Inferred` edges and keep only declared facts. The tier is -load-bearing — pick it honestly per edge. `Predicate::default_provenance()` -gives the calibrated default; override per-edge only when your frontend -can *prove* a stronger tier (e.g. a Rails frontend that statically -resolves a read can promote `reads_field` to `Authoritative`). - ---- - -## 3. The IR you fill (`ModelGraph`) - -```rust -pub struct ModelGraph { pub namespace: String, pub models: Vec } -pub struct Model { pub name: String, pub fields: Vec, pub functions: Vec } -pub struct Field { pub name: String, pub depends_on: Vec, pub emitted_by: Option } -pub struct Function { pub name: String, pub reads: Vec, pub raises: Vec, pub traverses: Vec } -``` - -That's the entire contract. Plain owned data, no behaviour. Fill it from -your AST, hand it to `expand()`. - -### Naming rule - -Keep `Model::name` as the source names it, with ONE normalisation: if the -host uses dots in model names (Odoo `account.move`), convert them to -underscores (`account_move`) so the IRI dot is unambiguously the -model↔member separator. Rails class names (`WorkPackage`) have no dots — -use them as-is. - ---- - -## 4. The query this enables ("a + b → c through d?") - -The reason for the graph: answer *"which field `c` does method `d` emit -when inputs `a` and `b` change?"* as a deterministic graph deduction, not -a similarity search: - -```text - { c : (c depends_on a) ∧ (c depends_on b) } then { d : (c emitted_by d) } -``` - -Two reverse `depends_on` lookups intersected, then one `emitted_by` -lookup. This is what makes the extracted ontology a *compute graph* -(Foundry-shape) rather than a flat list of routes. The -`lance_graph::graph::spo::action_emitter` composes per-method -`ActionSpec { effects, inputs, raises, reads, traverses }` records -straight off these edges. - ---- - -## 5. Writing a new frontend — the Ruby/Rails (OpenProject) guide - -Five steps. Only step 2 is real work. - -### Step 1 — pick a Ruby parser - -Options, cheapest first: - -- **`lib-ruby-parser`** (Rust crate, pure Rust, no Ruby runtime) — best - fit for a Rust frontend; gives you a typed AST. *Recommended.* -- **tree-sitter-ruby** (via the `tree-sitter` crate) — robust, lossy on - some semantics but great for structural sweeps. -- Shell out to Ruby's own `ripper`/`parser` gem and read s-expressions — - only if you already have a Ruby toolchain in the loop. - -A scaffold crate (`ruff_ruby_spo`, see §6) is provided wired for -`lib-ruby-parser` with `todo!()` markers at each extraction point. - -### Step 2 — map Rails constructs to the IR - -This is the whole job. The mapping (mirror of the Odoo column in the -cheat-sheet in `src/ir.rs`): - -| IR target | Rails / ActiveRecord source | -| --- | --- | -| `Model::name` | `class WorkPackage < ApplicationRecord` → `WorkPackage` | -| `Field::name` | DB columns (from `db/schema.rb`), `attribute :x`, `attr_accessor`, `store_accessor` | -| `Field::depends_on` | association chains a derived attribute reads (`time_entries.hours`); if you parse `schema.rb` you can also seed column→column deps | -| `Field::emitted_by` | a memoized/derived method that assigns the attribute (`def total_hours; @total_hours ||= …; end`) | -| `Function::name` | instance methods (`def compute_total_hours`) | -| `Function::reads` | `self.x` reads and bare attribute reads in the method body | -| `Function::raises` | `raise X`, `errors.add(...)`, and `validates`/`validate` callbacks (treat the validation as a guard that raises `ActiveRecord::RecordInvalid`) | -| `Function::traverses` | association walks in the body (`children.each`, `time_entries.map`, `project.members`) — the association name is the relation | - -Notes specific to Rails: - -- **Associations are your relations.** `belongs_to :project`, - `has_many :time_entries` declare the traversable relations. A method - body that calls `time_entries` is traversing `time_entries`. Seed the - set of valid relation names from the association declarations so you can - distinguish a relation walk from an ordinary method call. -- **Validations are guards.** `validates :subject, presence: true` and - `validate :custom_check` are the Rails analogue of Odoo's - `@api.constrains` + `raise`. Emit them as `raises exc:ActiveRecord::RecordInvalid` - (Authoritative) on the validating method, or on a synthetic - `_validate` function for declarative `validates`. -- **`exc:` namespace is shared.** Ruby exception class names keep their - `::` (`exc:ActiveRecord::RecordInvalid`) — the `exc:` prefix is the same - one Odoo uses (`exc:UserError`). Don't translate; just prefix. -- **Callbacks (`before_save`, `after_create`) → functions** whose - `traverses`/`reads`/`raises` you extract from the referenced method. - -### Step 3 — build the `ModelGraph` - -```rust -let mut graph = ModelGraph::new("openproject"); -for class in rails_classes { - let mut model = Model::new(normalise(&class.name)); - model.fields = extract_fields(&class); // step 2 - model.functions = extract_functions(&class); // step 2 - graph.models.push(model); -} -``` - -### Step 4 — expand + write - -```rust -use ruff_spo_triplet::{expand, to_ndjson}; -let triples = expand(&graph); // sorted, de-duplicated, truth-weighted -std::fs::write("openproject.spo.ndjson", to_ndjson(&triples))?; -``` - -### Step 5 — load downstream (already built, no new work) - -The ndjson loads directly into `lance_graph::graph::spo::odoo_ontology::load_ontology` -(rename or generalise that loader's name; the *format* is identical). -`action_emitter::emit_actions` and `link_chain::split_all_depends_on` -then work on the OpenProject graph exactly as they do on Odoo's. - ---- - -## 6. The scaffold crate (`ruff_ruby_spo`) - -`crates/ruff_ruby_spo/` is a compiling skeleton: - -- depends on `ruff_spo_triplet`, -- exposes `extract(source_tree: &Path) -> ModelGraph`, -- has `todo!()` bodies at each of the step-2 extraction points with a - doc-comment naming the exact Rails construct to read, -- ships a unit test that builds a hand-written `ModelGraph` and asserts - the `expand()` output — so the *target shape* is locked even before the - parser is wired. - -Start there: replace the `todo!()`s one predicate at a time, running the -locked-shape test after each. When all are filled, point it at the -OpenProject `app/models/` tree. - ---- - -## 7. Verifying parity with the Odoo extraction - -Two graphs are "the same shape" if, for a structurally-equivalent input, -they produce the same predicate histogram and the same truth tiers. The -crate's own tests pin this: - -- `triple::tests::provenance_truth_tiers_match_odoo_calibration` -- `expand::tests::truth_tiers_are_assigned_per_predicate` -- `integration_tests::two_model_graph_round_trips_through_ndjson` - (uses a Rails-shaped `ModelGraph`) - -When you wire the Ruby frontend, add a fixture test that runs a small -real OpenProject model through `extract()` + `expand()` and asserts the -expected `ActionSpec` shape downstream. That closes the loop: same IR -contract → same triples → same Foundry-shape actions. - ---- - -## 8. Pointers - -- `src/triple.rs` — the closed vocabulary (`Predicate`, `EntityKind`, `Provenance`). -- `src/ir.rs` — the `ModelGraph` contract + the Odoo↔Rails cheat-sheet. -- `src/expand.rs` — the deterministic IR→triples projection. -- `src/ndjson.rs` — the on-disk format (matches the `lance_graph` loader). -- Downstream consumers (in the `lance-graph` repo): - `crates/lance-graph/src/graph/spo/odoo_ontology.rs` (loader), - `…/action_emitter.rs` (Foundry `ActionSpec` composer), - `…/link_chain.rs` (dotted-path splitter). diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/expand.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/expand.rs deleted file mode 100644 index f8e9b55..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/expand.rs +++ /dev/null @@ -1,2652 +0,0 @@ -//! The expander — the single deterministic projection from [`ModelGraph`] -//! IR to a sorted, de-duplicated `Vec`. -//! -//! This is the whole point of the crate: one function, called by every -//! language frontend, so the SPO graph is identical regardless of source -//! language. Determinism is structural — output is sorted by `(s, p, o)` -//! and de-duplicated, so two runs over the same IR are byte-identical. - -use std::collections::BTreeSet; - -use crate::ir::{ - ActsAs, AssocDecl, AssocKind, AttrDecl, AttrKind, Callback, ConcernKind, ConcernRef, - ConstexprKind, CppAccess, CppBase, CppField, CppFriend, CppMacroUse, CppMethod, - CppStaticAssert, CppTemplate, CppTemplateKind, Delegation, DslCall, DynMethod, GemDsl, GemKind, - Model, ModelGraph, ScopeDecl, ScopeKind, StiInfo, UsingRef, Validation, ValidationKind, -}; -use crate::triple::{EntityKind, Predicate, Provenance, Triple}; - -/// Expand a [`ModelGraph`] into canonical SPO triples. -/// -/// # Emission rules — core 7 (per model) -/// -/// 1. `(ns:model, rdf:type, ogit:ObjectType)` — Structural. -/// 2. For each field: -/// - `(ns:model.field, rdf:type, ogit:Property)` — Structural. -/// - `(ns:model, has_field, ns:model.field)` — Structural (the ownership -/// edge, mirroring `has_function`; lets `soc` count core-7 fields as -/// data members, same shape the C++ `cpp_field` path emits). -/// 3. For each function: -/// - `(ns:model.fn, rdf:type, ogit:Function)` — Structural. -/// - `(ns:model, has_function, ns:model.fn)` — Structural. -/// 4. For each field with `emitted_by`: -/// `(ns:model.field, emitted_by, ns:model.fn)` — Authoritative. -/// 5. For each field dependency: -/// `(ns:model.field, depends_on, ns:model.)` — Authoritative. -/// 6. For each function read: -/// `(ns:model.fn, reads_field, ns:model.field)` — Inferred. -/// 7. For each function raise: -/// `(ns:model.fn, raises, exc:)` — Authoritative. -/// 8. For each function traversal: -/// `(ns:model.fn, traverses_relation, ns:model.)` — Inferred. -/// -/// `depends_on` and `traverses_relation` objects are emitted verbatim as -/// dotted paths under the model IRI — the [`crate`]-downstream link-chain -/// splitter (`lance_graph::graph::spo::link_chain`) decomposes them into -/// per-hop link triples; this crate does NOT pre-split them, keeping the -/// emitter source-faithful. -/// -/// # Emission rules — `OpenProject` AR-shape (per model) -/// -/// 9. For each `associations[i]`: -/// `(ns:model, declares_association, ns:model.)` — `OpenProjectExtracted`. -/// 10. For each `validations[i]`: -/// - kind ≠ Normalizes → `(ns:model, validates_constraint, )` — `OpenProjectExtracted`. -/// - kind == Normalizes → `(ns:model, normalizes_attribute, )` — `OpenProjectExtracted`. -/// 11. For each `callbacks[i]`: -/// `(ns:model, has_callback, ":")` — `OpenProjectExtracted`. -/// 12. For each `concerns[i]` (by kind): -/// - Include → `includes_module`, Extend → `extends_module`, -/// Prepend → `prepends_module` — `OpenProjectExtracted`. -/// - `ClassMethodsBlock` → `concern_class_methods`, `IncludedBlock` → -/// `concern_included_block` — Structural (block declarations are -/// structural-by-construction). -/// 13. For each `attributes[i]` (by kind): -/// - Attribute / `AttrAccessor` / `AttrReader` / `AttrReadonly` / -/// `StoreAttribute` / `StoreAccessor` / Serialize / Enum / -/// `DefineAttributeMethod` → `(ns:model, has_attribute, )` — -/// `OpenProjectExtracted`. -/// - `AliasAttribute` → `aliases_attribute`. -/// - `AliasMethod` / Alias → `aliases_method`. -/// - `UndefMethod` → `column_override` (marks a column as undefined). -/// 14. For each `delegations[i]`, expand one triple per delegated method: -/// `(ns:model, delegates_to, "=>via:")` — `OpenProjectExtracted`. -/// 15. For each `scopes[i]` (by kind): -/// - Scope → `has_scope`, `DefaultScope` → `has_default_scope`, -/// Scopes (OP plural) → `has_scope` (one triple per name). -/// 16. For each `acts_as[i]`: -/// `(ns:model, acts_as, "[:]")` — `OpenProjectExtracted`. -/// 17. For each `dsl_calls[i]`, route by name: -/// - `register_journal_formatter` → `registers_journal_formatter`, -/// - `register_journal_formatted_fields` → `registers_journal_formatted_fields`, -/// - everything else → `has_dsl_call` (catch-all). -/// 18. For each `gem_dsl[i]` (by gem): -/// - `MountUploader` → `mounts_uploader`, `HasPaperTrail` → -/// `has_paper_trail`, `HasClosureTree` → `has_closure_tree`, -/// `CounterCulture` → `counter_cultures`, `AutoStripAttributes` → -/// `auto_strips`. -/// 19. For each `dynamic_methods[i]`: -/// `(ns:model, defines_method, "=")` — Inferred -/// (per-edge, since dynamism is the whole reason for the variant). -/// 20. For each `refinements[i]`: -/// `(ns:model, uses_refinement, )` — `OpenProjectExtracted`. -/// 21. If `sti.is_some()`: emit `(ns:model, inherits_from, )` -/// for `sti.inherits_from` — `OpenProjectExtracted`. Reuses the -/// `inherits_from` predicate originally introduced for C++ class -/// inheritance (wire shape is identical; per-emission provenance -/// differs). `abstract_class` / `inheritance_column` are metadata -/// only. -/// -/// # Determinism -/// -/// The returned Vec is sorted by `(s, p, o)` and de-duplicated. Truth -/// values do not participate in ordering or de-duplication — if the same -/// `(s, p, o)` is produced twice with different provenance, the -/// first-in-sort-order (which, after sort, is deterministic but provenance- -/// arbitrary) wins. Frontends should not emit conflicting provenance for -/// one identity; [`crate::ndjson`] round-trips assume a clean IR. -#[must_use] -pub fn expand(graph: &ModelGraph) -> Vec { - let mut exp = Expander::new(); - for model in &graph.models { - exp.model(&graph.namespace, model); - } - exp.finish() -} - -struct Expander { - triples: Vec, - set: BTreeSet<(String, String, String)>, -} - -impl Expander { - fn new() -> Self { - Self { - triples: Vec::new(), - set: BTreeSet::new(), - } - } - - fn push(&mut self, s: String, p: Predicate, o: String, prov: Provenance) { - let key = (s.clone(), p.as_str().to_string(), o.clone()); - if self.set.insert(key) { - self.triples.push(Triple::new(s, p, o, prov)); - } - } - - fn finish(mut self) -> Vec { - self.triples.sort_by(|a, b| a.key().cmp(&b.key())); - self.triples - } - - fn model(&mut self, ns: &str, model: &Model) { - let model_iri = format!("{ns}:{}", model.name); - - // 1. model rdf:type ObjectType - self.push( - model_iri.clone(), - Predicate::RdfType, - EntityKind::ObjectType.iri().to_string(), - Provenance::Structural, - ); - - // 2 + 4 + 5. fields - for field in &model.fields { - let field_iri = format!("{model_iri}.{}", field.name); - self.push( - field_iri.clone(), - Predicate::RdfType, - EntityKind::Property.iri().to_string(), - Provenance::Structural, - ); - // Ownership edge (model, has_field, field) — mirrors the methods' - // `has_function` below and the C++ `cpp_field` path. Without it, - // `soc_findings` (which buckets data members from `has_field`) never - // sees core-7 (Odoo/Rails) fields, so the per-field `field_type` - // would be emitted but the model would have zero SoC data members. - self.push( - model_iri.clone(), - Predicate::HasField, - field_iri.clone(), - Provenance::Structural, - ); - if let Some(fn_name) = &field.emitted_by { - self.push( - field_iri.clone(), - Predicate::EmittedBy, - format!("{model_iri}.{fn_name}"), - Provenance::Authoritative, - ); - } - for dep in &field.depends_on { - self.push( - field_iri.clone(), - Predicate::DependsOn, - format!("{model_iri}.{dep}"), - Provenance::Authoritative, - ); - } - if let Some(target) = &field.target { - self.push( - field_iri.clone(), - Predicate::Target, - target.clone(), - Provenance::Authoritative, - ); - } - if let Some(inverse_name) = &field.inverse_name { - self.push( - field_iri.clone(), - Predicate::InverseName, - inverse_name.clone(), - Provenance::Authoritative, - ); - } - if let Some(relation_kind) = &field.relation_kind { - self.push( - field_iri.clone(), - Predicate::RelationKind, - relation_kind.clone(), - Provenance::Authoritative, - ); - } - // Scalar fields carry their Odoo constructor here (mutually - // exclusive with `relation_kind`). Reuses the `field_type` - // predicate the Rails AttrDecl path already emits, so the - // closed vocab is unchanged. - if let Some(field_type) = &field.field_type { - self.push( - field_iri.clone(), - Predicate::FieldType, - field_type.clone(), - Provenance::Authoritative, - ); - } - // Schema stratum (D-AR-3.5): a `null: false` column constraint - // from the migration DSL. Only the positive fact is emitted — - // absence means nullable (the Rails default). - if field.not_null == Some(true) { - self.push( - field_iri.clone(), - Predicate::ColumnNotNull, - "true".to_string(), - Provenance::Authoritative, - ); - } - } - - // 3 + 6 + 7 + 8. functions - for func in &model.functions { - let fn_iri = format!("{model_iri}.{}", func.name); - self.push( - fn_iri.clone(), - Predicate::RdfType, - EntityKind::Function.iri().to_string(), - Provenance::Structural, - ); - self.push( - model_iri.clone(), - Predicate::HasFunction, - fn_iri.clone(), - Provenance::Structural, - ); - for read in &func.reads { - self.push( - fn_iri.clone(), - Predicate::ReadsField, - format!("{model_iri}.{read}"), - Provenance::Inferred, - ); - } - for exc in &func.raises { - self.push( - fn_iri.clone(), - Predicate::Raises, - format!("exc:{exc}"), - Provenance::Authoritative, - ); - } - for rel in &func.traverses { - self.push( - fn_iri.clone(), - Predicate::TraversesRelation, - format!("{model_iri}.{rel}"), - Provenance::Inferred, - ); - } - // Command-shape facts (E-ACCIDENTAL-IMPERATIVE / OGAR F17): the - // write-side counterpart of `reads_field` / `traverses_relation`. - for write in &func.writes { - self.push( - fn_iri.clone(), - Predicate::WritesField, - format!("{model_iri}.{write}"), - Provenance::Authoritative, - ); - } - // `calls` objects are raw `"receiver.method"` strings (NOT IRIs): - // the receiver may be a local / relation / const / self, so the - // object is emitted verbatim like `target` / `field_type`. - for call in &func.calls { - self.push( - fn_iri.clone(), - Predicate::Calls, - call.clone(), - Provenance::Inferred, - ); - } - } - - // ───── OpenProject AR-shape ───── - - for assoc in &model.associations { - self.association(&model_iri, assoc); - } - for v in &model.validations { - self.validation(&model_iri, v); - } - for cb in &model.callbacks { - self.callback(&model_iri, cb); - } - for cr in &model.concerns { - self.concern(&model_iri, cr); - } - for a in &model.attributes { - self.attribute(&model_iri, a); - } - for d in &model.delegations { - self.delegation(&model_iri, d); - } - for s in &model.scopes { - self.scope(&model_iri, s); - } - for aa in &model.acts_as { - self.acts_as(&model_iri, aa); - } - for dc in &model.dsl_calls { - self.dsl_call(&model_iri, dc); - } - for g in &model.gem_dsl { - self.gem_dsl(&model_iri, g); - } - for dm in &model.dynamic_methods { - self.dynamic_method(&model_iri, dm); - } - for r in &model.refinements { - self.refinement(&model_iri, r); - } - if let Some(sti) = &model.sti { - self.sti(ns, &model_iri, sti); - } - for parent in &model.inherits { - // Frontend-agnostic inheritance (Odoo `_inherit`). Reuses the - // `inherits_from` predicate (introduced for C++ bases, also used - // by Rails STI) — one edge per parent, authoritatively declared - // in the source. Self-edges are excluded by the frontend. - self.push( - model_iri.clone(), - Predicate::InheritsFrom, - format!("{ns}:{parent}"), - Provenance::Authoritative, - ); - } - - // ───── C++ machine-plane ───── - - for base in &model.bases { - self.cpp_base(ns, &model_iri, base); - } - for field in &model.member_fields { - self.cpp_field(&model_iri, field); - } - for method in &model.methods { - self.cpp_method(ns, &model_iri, method); - } - for tpl in &model.templates { - self.cpp_template(&model_iri, tpl); - } - for fr in &model.friends { - self.cpp_friend(&model_iri, fr); - } - for mu in &model.macro_uses { - self.cpp_macro_use(&model_iri, mu); - } - for sa in &model.static_asserts { - self.cpp_static_assert(&model_iri, sa); - } - } - - fn association(&mut self, model_iri: &str, a: &AssocDecl) { - // The existence-of-relation fact, kind-agnostic. - let rel_iri = format!("{model_iri}.{}", a.name); - self.push( - model_iri.to_string(), - Predicate::DeclaresAssociation, - rel_iri.clone(), - Provenance::OpenProjectExtracted, - ); - // Kind sibling — only the consumer that cares about FK direction - // reads this; other consumers can ignore it. - // - // Why split into two triples (instead of encoding kind in the - // `declares_association` object): the existence fact is queried - // by many consumers (lance-graph SPO store, graph-traversal, - // ndjson roundtrip tests); the kind is only needed by schema - // codegen. Two predicates keep the existence query cheap. - let kind_str = match a.kind { - AssocKind::BelongsTo => "belongs_to", - AssocKind::HasMany => "has_many", - AssocKind::HasOne => "has_one", - AssocKind::HasAndBelongsToMany => "has_and_belongs_to_many", - AssocKind::AcceptsNestedAttributesFor => "accepts_nested_attributes_for", - }; - self.push( - rel_iri.clone(), - Predicate::AssociationKind, - kind_str.to_string(), - Provenance::OpenProjectExtracted, - ); - // `class_name:` override: Rails lets a relation point at a class - // whose name doesn't follow the camelcase-singular convention - // (`belongs_to :owner, class_name: 'User'`). Emit a sibling - // `class_name` triple so the downstream Schema consumer can - // resolve the target class instead of inventing `record`. - // Absence of this triple means "use the convention" — preserves - // pre-#15 ndjson dumps bit-for-bit. - // - // The Ruby parser (`ruff_ruby_spo`) stores option values via - // `render_node`, which preserves source-level quote characters — - // a literal `class_name: "User"` ends up in `options` as the - // string `"User"` (8 chars including the quote pair). Strip - // those before emission so the consumer sees the bare class - // name `User` (codex P2 on #18). - if let Some((_, raw_override)) = a.options.iter().find(|(k, _)| k == "class_name") { - let normalized = strip_class_name_marker(raw_override); - self.push( - rel_iri, - Predicate::ClassName, - normalized.to_string(), - Provenance::OpenProjectExtracted, - ); - } - } - - fn validation(&mut self, model_iri: &str, v: &Validation) { - let pred = match v.kind { - ValidationKind::Normalizes => Predicate::NormalizesAttribute, - ValidationKind::Validates - | ValidationKind::Validate - | ValidationKind::ValidatesAssociated - | ValidationKind::ValidatesEach => Predicate::ValidatesConstraint, - }; - self.push( - model_iri.to_string(), - pred, - v.target.clone(), - Provenance::OpenProjectExtracted, - ); - // For `validates :attr, presence: true, uniqueness: true`-style - // declarations, surface each recognised validation kind as a - // sibling triple keyed by the validated-attribute IRI. The - // downstream Schema consumer reads these to gate richer - // SurrealQL clauses (presence → ASSERT $value != NONE, - // uniqueness → UNIQUE INDEX, etc.) — without them, every - // validation collapses to the catch-all non-null ASSERT. - // - // Multiple kinds per validation emit multiple triples - // (declaration order). The `Normalizes` / `Validate` block - // forms carry no kind options on the IR, so they emit zero - // `validation_kind` triples (correct — there's no schema - // contribution to make). - if matches!( - v.kind, - ValidationKind::Validates - | ValidationKind::ValidatesAssociated - | ValidationKind::ValidatesEach - ) { - let attr_iri = format!("{model_iri}.{}", v.target); - for kind in extract_validation_kinds(&v.options) { - self.push( - attr_iri.clone(), - Predicate::ValidationKind, - kind.to_string(), - Provenance::OpenProjectExtracted, - ); - // Parametric kinds (`length: { maximum: N }`) carry - // their inner-hash options on the option value — a - // sibling `validation_param` triple per inner key - // surfaces them. Non-hash kinds (`presence: true`) - // emit zero param triples (correct — there's - // nothing to lower beyond the kind itself). - let value = v - .options - .iter() - .find(|(k, _)| k == kind) - .map(|(_, v)| v.as_str()) - .unwrap_or(""); - for (inner_key, inner_value) in extract_hash_options(value) { - self.push( - attr_iri.clone(), - Predicate::ValidationParam, - format!("{kind}:{inner_key}={inner_value}"), - Provenance::OpenProjectExtracted, - ); - } - } - } - } - - fn callback(&mut self, model_iri: &str, cb: &Callback) { - self.push( - model_iri.to_string(), - Predicate::HasCallback, - format!("{}:{}", cb.phase, cb.target), - Provenance::OpenProjectExtracted, - ); - } - - fn concern(&mut self, model_iri: &str, cr: &ConcernRef) { - let (pred, prov) = match cr.kind { - ConcernKind::Include => (Predicate::IncludesModule, Provenance::OpenProjectExtracted), - ConcernKind::Extend => (Predicate::ExtendsModule, Provenance::OpenProjectExtracted), - ConcernKind::Prepend => (Predicate::PrependsModule, Provenance::OpenProjectExtracted), - ConcernKind::ClassMethodsBlock => { - (Predicate::ConcernClassMethods, Provenance::Structural) - } - ConcernKind::IncludedBlock => (Predicate::ConcernIncludedBlock, Provenance::Structural), - }; - let o = match cr.kind { - ConcernKind::ClassMethodsBlock | ConcernKind::IncludedBlock => { - cr.body_ref.clone().unwrap_or_else(|| "".to_string()) - } - _ => cr.module.clone(), - }; - self.push(model_iri.to_string(), pred, o, prov); - } - - fn attribute(&mut self, model_iri: &str, a: &AttrDecl) { - let pred = match a.kind { - AttrKind::AliasAttribute => Predicate::AliasesAttribute, - AttrKind::AliasMethod | AttrKind::Alias => Predicate::AliasesMethod, - AttrKind::UndefMethod => Predicate::ColumnOverride, - AttrKind::Attribute - | AttrKind::AttrAccessor - | AttrKind::AttrReader - | AttrKind::AttrReadonly - | AttrKind::StoreAttribute - | AttrKind::StoreAccessor - | AttrKind::Serialize - | AttrKind::Enum - | AttrKind::DefineAttributeMethod => Predicate::HasAttribute, - }; - self.push( - model_iri.to_string(), - pred, - a.name.clone(), - Provenance::OpenProjectExtracted, - ); - // D-AR-5.2: emit `(model.field, field_type, "")` - // when the AttrDecl carries an explicit type annotation. The - // Rails AST literal (`attribute :age, :integer` → - // `options[("type", "integer")]`) is the static type signal - // the downstream Schema consumer needs to upgrade `Kind::Any` - // into a concrete SurrealQL kind. - if let Some(rails_type) = field_type_from_options(&a.options) { - self.push( - format!("{model_iri}.{}", a.name), - Predicate::FieldType, - rails_type, - Provenance::OpenProjectExtracted, - ); - } - } - - fn delegation(&mut self, model_iri: &str, d: &Delegation) { - // Honour Rails' `prefix:` option (codex P2 PR #5): - // delegate :name, :identifier, to: :project, prefix: true - // → exposes `project_name` and `project_identifier` on the - // caller (NOT `name` / `identifier`). - // delegate :name, to: :project, prefix: :owner - // → exposes `owner_name`. - // Without honouring this, the graph would record an edge to a - // method that doesn't exist (`name`) while queries for the real - // method (`project_name`) would miss. - let prefix = delegate_prefix(d); - for method in &d.methods { - let exposed = match &prefix { - Some(p) => format!("{p}_{method}"), - None => method.clone(), - }; - self.push( - model_iri.to_string(), - Predicate::DelegatesTo, - format!("{exposed}=>via:{}", d.to), - Provenance::OpenProjectExtracted, - ); - } - } - - fn scope(&mut self, model_iri: &str, s: &ScopeDecl) { - match s.kind { - ScopeKind::Scope => { - self.push( - model_iri.to_string(), - Predicate::HasScope, - format!("{}={}", s.name, s.body_ref), - Provenance::OpenProjectExtracted, - ); - } - ScopeKind::DefaultScope => { - self.push( - model_iri.to_string(), - Predicate::HasDefaultScope, - s.body_ref.clone(), - Provenance::OpenProjectExtracted, - ); - } - ScopeKind::Scopes => { - // OP plural form: one HasScope per name; body_ref carries - // a placeholder (the plural form has no per-scope lambda). - self.push( - model_iri.to_string(), - Predicate::HasScope, - format!("{}={}", s.name, s.body_ref), - Provenance::OpenProjectExtracted, - ); - } - } - } - - fn acts_as(&mut self, model_iri: &str, aa: &ActsAs) { - let obj = if aa.options.is_empty() { - aa.variant.clone() - } else { - let opts = aa - .options - .iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(","); - format!("{}:{}", aa.variant, opts) - }; - self.push( - model_iri.to_string(), - Predicate::ActsAs, - obj, - Provenance::OpenProjectExtracted, - ); - } - - fn dsl_call(&mut self, model_iri: &str, dc: &DslCall) { - let pred = match dc.name.as_str() { - "register_journal_formatter" => Predicate::RegistersJournalFormatter, - "register_journal_formatted_fields" => Predicate::RegistersJournalFormattedFields, - _ => Predicate::HasDslCall, - }; - let obj = match pred { - Predicate::HasDslCall => format!("{}({})", dc.name, dc.args), - _ => dc.args.clone(), - }; - self.push( - model_iri.to_string(), - pred, - obj, - Provenance::OpenProjectExtracted, - ); - } - - fn gem_dsl(&mut self, model_iri: &str, g: &GemDsl) { - let pred = match g.gem { - GemKind::MountUploader => Predicate::MountsUploader, - GemKind::HasPaperTrail => Predicate::HasPaperTrail, - GemKind::HasClosureTree => Predicate::HasClosureTree, - GemKind::CounterCulture => Predicate::CounterCultures, - GemKind::AutoStripAttributes => Predicate::AutoStrips, - }; - self.push( - model_iri.to_string(), - pred, - g.args.clone(), - Provenance::OpenProjectExtracted, - ); - } - - fn dynamic_method(&mut self, model_iri: &str, dm: &DynMethod) { - self.push( - model_iri.to_string(), - Predicate::DefinesMethod, - format!("{}={}", dm.name_expr, dm.body_ref), - Provenance::Inferred, - ); - } - - fn refinement(&mut self, model_iri: &str, r: &UsingRef) { - self.push( - model_iri.to_string(), - Predicate::UsesRefinement, - r.refinement_module.clone(), - Provenance::OpenProjectExtracted, - ); - } - - fn sti(&mut self, ns: &str, model_iri: &str, sti: &StiInfo) { - if let Some(parent) = &sti.inherits_from { - // Rails STI parents lower to `InheritsFrom` (same predicate - // C++ uses for class inheritance) — the wire shape is - // identical: subject = class, object = namespaced base - // IRI (`openproject:Issue`, NOT bare `Issue`). The - // namespace makes the parent joinable to its own - // `ObjectType` declaration — without it, downstream - // graph traversal can't link the inherits_from edge to - // the parent class node (codex P2 on #19). - // - // The distinction from `IncludesModule` matters - // semantically: STI is single-table inheritance with a - // type-discriminator column, while module inclusion is - // method-body composition. Downstream consumers (graph - // build, schema visualizer) need both signals separately - // to render the type hierarchy correctly. - self.push( - model_iri.to_string(), - Predicate::InheritsFrom, - format!("{ns}:{parent}"), - Provenance::OpenProjectExtracted, - ); - } - // abstract_class / inheritance_column carried on IR only. - let _ = sti.abstract_class; - let _ = &sti.inheritance_column; - } - - // ───── C++ machine-plane arms ───── - - fn cpp_base(&mut self, ns: &str, model_iri: &str, base: &CppBase) { - // Access specifier + virtual-inheritance flag are carried on the IR - // (CppBase) but NOT emitted — the object stays a clean base-class - // IRI for graph traversal (mirrors `association` dropping AssocKind). - let _ = base.access; - let _ = base.virtual_base; - self.push( - model_iri.to_string(), - Predicate::InheritsFrom, - format!("{ns}:{}", base.name), - Provenance::CppExtracted, - ); - } - - fn cpp_field(&mut self, model_iri: &str, field: &CppField) { - let field_iri = format!("{model_iri}.{}", field.name); - // Structural classification, then the ownership edge. - self.push( - field_iri.clone(), - Predicate::RdfType, - EntityKind::Property.iri().to_string(), - Provenance::Structural, - ); - let _ = &field.type_name; // carried on IR for catalog consumers - self.push( - model_iri.to_string(), - Predicate::HasField, - field_iri, - Provenance::CppExtracted, - ); - } - - fn cpp_method(&mut self, ns: &str, model_iri: &str, method: &CppMethod) { - // Per-overload identity: append `()` so - // overloaded methods (`void f(int)` + `void f(double)`) get distinct - // method IRIs (`Foo.f(int)` vs `Foo.f(double)`). Otherwise their - // signature triples (returns_type / has_param_type / is_noexcept …) all - // attach to the same `Foo.f` node and the codegen can't reconstruct the - // two separate overloads. Codex P2 #17. - // - // The cv-qualifier is part of the identity too: a const/non-const - // overload pair (`T& at(i)` vs `const T& at(i) const`) shares name AND - // param types, so without ` const` in the IRI they collapse under the - // `(s, p, o)` dedup — the GAP-CONST-OVERLOAD defect (19/67 ccutil - // classes). Appending ` const` (the C++ spelling) keeps the two - // overloads on distinct nodes. `reassemble` reconstructs the same - // suffix from the recovered `is_const`. - let method_iri = format!( - "{model_iri}.{}({}){}", - method.name, - method.param_types.join(","), - if method.is_const { " const" } else { "" } - ); - // Universal classification — same shape the core 7 give a Function. - self.push( - method_iri.clone(), - Predicate::RdfType, - EntityKind::Function.iri().to_string(), - Provenance::Structural, - ); - self.push( - model_iri.to_string(), - Predicate::HasFunction, - method_iri.clone(), - Provenance::Structural, - ); - // Property flags → method-property predicates. - if method.is_pure_virtual { - self.push( - method_iri.clone(), - Predicate::IsPureVirtual, - "true".to_string(), - Provenance::CppExtracted, - ); - } - if let Some(kind) = method.constexpr_kind { - let marker = match kind { - ConstexprKind::Constexpr => "constexpr", - ConstexprKind::Consteval => "consteval", - }; - self.push( - method_iri.clone(), - Predicate::IsConstexpr, - marker.to_string(), - Provenance::CppExtracted, - ); - } - if method.is_noexcept { - self.push( - method_iri.clone(), - Predicate::IsNoexcept, - "true".to_string(), - Provenance::CppExtracted, - ); - } - if let Some(base_method) = &method.overrides { - self.push( - method_iri.clone(), - Predicate::VirtuallyOverrides, - format!("{ns}:{base_method}"), - Provenance::CppExtracted, - ); - } - if let Some(op) = &method.operator_kind { - self.push( - method_iri.clone(), - Predicate::DefinesOperator, - op.clone(), - Provenance::CppExtracted, - ); - } - // AST-DLL signature shape: return type (one edge) + parameter types (one - // edge each, `:` so the unordered triple set keeps order). - if let Some(ret) = &method.return_type { - self.push( - method_iri.clone(), - Predicate::ReturnsType, - ret.clone(), - Provenance::CppExtracted, - ); - } - for (i, param) in method.param_types.iter().enumerate() { - self.push( - method_iri.clone(), - Predicate::HasParamType, - format!("{i}:{param}"), - Provenance::CppExtracted, - ); - } - // ORM-downcast shape: const = read accessor, static = class-level. - if method.is_const { - self.push( - method_iri.clone(), - Predicate::IsConst, - "true".to_string(), - Provenance::CppExtracted, - ); - } - if method.is_static { - self.push( - method_iri.clone(), - Predicate::IsStatic, - "true".to_string(), - Provenance::CppExtracted, - ); - } - // Access specifier — always present (every method has a visibility). - self.push( - method_iri.clone(), - Predicate::HasVisibility, - match method.access { - CppAccess::Public => "public", - CppAccess::Protected => "protected", - CppAccess::Private => "private", - } - .to_string(), - Provenance::CppExtracted, - ); - if let Some(req) = &method.requires_clause { - // Last potential use of `method_iri` — move, don't clone. - self.push( - method_iri, - Predicate::RequiresConcept, - req.clone(), - Provenance::CppExtracted, - ); - } - } - - fn cpp_template(&mut self, model_iri: &str, tpl: &CppTemplate) { - let (pred, prov) = match tpl.kind { - CppTemplateKind::Specialisation => { - (Predicate::TemplateSpecialises, Provenance::CppExtracted) - } - CppTemplateKind::Instantiation => { - (Predicate::TemplateInstantiates, Provenance::Inferred) - } - }; - self.push(model_iri.to_string(), pred, tpl.name.clone(), prov); - } - - fn cpp_friend(&mut self, model_iri: &str, fr: &CppFriend) { - self.push( - model_iri.to_string(), - Predicate::IsFriendOf, - fr.name.clone(), - Provenance::Structural, - ); - } - - fn cpp_macro_use(&mut self, model_iri: &str, mu: &CppMacroUse) { - self.push( - model_iri.to_string(), - Predicate::UsesMacroExpansion, - format!("{}<={}", mu.identifier, mu.macro_name), - Provenance::Inferred, - ); - } - - fn cpp_static_assert(&mut self, model_iri: &str, sa: &CppStaticAssert) { - self.push( - model_iri.to_string(), - Predicate::StaticAsserts, - sa.condition.clone(), - Provenance::CppExtracted, - ); - } -} - -/// Resolve the Rails type annotation from `AttrDecl::options`. -/// -/// The extractor stores the type-positional Sym (e.g. the `:integer` -/// in `attribute :age, :integer`) as `options = [("type", "integer")]`. -/// Returns `None` when no explicit type is recorded. -fn field_type_from_options(options: &[(String, String)]) -> Option { - options - .iter() - .find_map(|(k, v)| (k == "type" && !v.is_empty()).then(|| v.clone())) -} - -/// Strip surrounding Ruby-source markers (string-literal quotes, -/// symbol leading colon) from a Rails option value, returning the -/// bare class name slice. -/// -/// `ruff_ruby_spo::walk::render_node` stores option values verbatim -/// from source: -/// -/// - `class_name: "User"` (string literal) → `"User"` (quote pair) -/// - `class_name: 'User'` (single-quoted) → `'User'` (quote pair) -/// - `class_name: User` (bare const ref) → `User` -/// - `class_name: :User` (symbol literal) → `:User` (leading colon) -/// -/// Downstream FK resolvers compare the value against bare class -/// names like `User`. All four input forms must collapse to the -/// bare name before emission, or the override silently fails for -/// the more common quoted forms. -/// -/// Inputs without a matching marker pass through unchanged. -fn strip_class_name_marker(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) -} - -/// Recognise Rails validation-kind option keys in declaration -/// order. `validates :email, presence: true, uniqueness: true` -/// yields `["presence", "uniqueness"]`. -/// -/// The closed set covers the validation kinds Rails ships in -/// `ActiveModel::Validations`. Unknown option keys (e.g. `if:`, -/// `on:`, `message:`) are filter-side conditions that don't change -/// the validation kind — they pass through silently. -/// -/// **Gating** — if the validation carries any gating option that -/// makes it conditional (`if:`, `unless:`, `on:`, `allow_nil:`, -/// `allow_blank:`) the function returns an empty Vec, suppressing -/// all `validation_kind` emission for this validation. The -/// rationale (codex P2 on #21): -/// `validates :age, numericality: true, allow_nil: true` is a -/// conditional Rails constraint — the schema-level -/// `ASSERT type::is_number($value)` would over-enforce by -/// rejecting NONE, which Rails would have allowed. The -/// schema-quality consumer falls back to the catch-all presence -/// behaviour from the `validates_constraint` triple, which is the -/// safer default. -/// -/// **Falsy validator values** — `validates :foo, presence: false` -/// is a no-op in Rails. Skip such keys so the schema doesn't -/// invent a constraint where none exists. -/// -/// Returns in source order (preserving Rails declaration order), -/// no dedup — duplicate keys in the same declaration are -/// vanishingly rare and a downstream consumer that cares can -/// dedupe. -fn extract_validation_kinds(options: &[(String, String)]) -> Vec<&'static str> { - const RECOGNISED: &[&str] = &[ - "presence", - "uniqueness", - "length", - "format", - "numericality", - "inclusion", - "exclusion", - "acceptance", - "confirmation", - // Codex P2 on #21: `absence` (inverse-of-presence) and - // `comparison` (>, <, >=, <=) are built-in Rails validators - // too. The schema consumer maps `absence` to - // `$value == NONE` and `comparison` to a presence-style - // fallback (parametric — the comparand isn't on the kind - // triple yet). - "absence", - "comparison", - ]; - // Codex P2 on #21: any of these gating options makes the - // validation conditional in Rails — suppress kind emission so - // the schema doesn't invent an unconditional constraint. - const GATING_OPTIONS: &[&str] = &["if", "unless", "on", "allow_nil", "allow_blank"]; - if options - .iter() - .any(|(k, _)| GATING_OPTIONS.contains(&k.as_str())) - { - return Vec::new(); - } - options - .iter() - .filter_map(|(k, v)| { - RECOGNISED - .iter() - .copied() - .find(|r| *r == k.as_str()) - .filter(|_| !is_falsy_validator_value(v)) - }) - .collect() -} - -/// `validates :foo, presence: false` / `presence: nil` is a no-op -/// in Rails. Returning `true` here causes the corresponding kind to -/// be skipped (codex P2 on #21). -/// -/// Hash-form values like `length: { maximum: 255 }` render via -/// `as_hash_options` and arrive verbatim (e.g. `{maximum: 255}`) -/// — those are truthy. -fn is_falsy_validator_value(v: &str) -> bool { - matches!(v.trim(), "false" | "nil") -} - -/// Parse a verbatim hash-literal option value like `{maximum: 255}` -/// or `{maximum: 255, minimum: 3}` into `(key, value)` pairs, in -/// declaration order. -/// -/// Returns an empty Vec if the input doesn't open with `{` (e.g. -/// boolean / nil / array literals) — non-hash kinds emit no -/// `validation_param` triples, only the parent `validation_kind`. -/// -/// **Format contract** (mirrors `walk::format_hash_inline`): -/// -/// - Outer `{}` delimiters. -/// - Pairs separated by `, ` at the TOP level only — nested -/// `{}` / `[]` / parens are tracked via a depth counter, so -/// commas inside them don't split. -/// - Each pair is `key: value` (colon-separated, single space). -/// - Whitespace around the colon and around commas is trimmed. -/// - Values are passed through verbatim (Ruby render of the AST -/// node — strings keep their quotes, symbols keep their `:`, -/// nested hashes keep their `{}` etc.). -/// -/// Malformed input (unbalanced delimiters, missing `:`) returns -/// what could be parsed so far — best-effort, never panics. -fn extract_hash_options(s: &str) -> Vec<(String, String)> { - let trimmed = s.trim(); - let Some(inner) = trimmed.strip_prefix('{').and_then(|t| t.strip_suffix('}')) else { - return Vec::new(); - }; - let mut out = Vec::new(); - let mut depth: i32 = 0; - let mut start = 0usize; - let inner_bytes = inner.as_bytes(); - for (i, &c) in inner_bytes.iter().enumerate() { - match c { - b'{' | b'[' | b'(' => depth += 1, - b'}' | b']' | b')' => depth = depth.saturating_sub(1), - b',' if depth == 0 => { - if let Some(pair) = split_kv(&inner[start..i]) { - out.push(pair); - } - start = i + 1; - } - _ => {} - } - } - if start < inner.len() { - if let Some(pair) = split_kv(&inner[start..]) { - out.push(pair); - } - } - out -} - -/// Split a single `key: value` pair on the first top-level `:`. -/// Returns `None` if the segment has no usable colon. -/// -/// **Symbol-key handling (codex P2 on #25)** — -/// `walk::format_hash_inline` renders Ruby symbol keys via -/// `render_node`, which prefixes them with `:`. So -/// `validates :name, length: { maximum: 255 }` arrives here as -/// the segment `":maximum: 255"`. Strip a single leading `:` from -/// the trimmed segment before the colon split so the key resolves -/// to `"maximum"`, not the empty string. -/// -/// String-key hashes (`{ "x" => 1 }`) and rocket-form hashes are -/// not produced by `format_hash_inline` (it only emits the -/// symbol-key sugar form), so this stripping is safe for every -/// shape the upstream emitter produces today. -fn split_kv(s: &str) -> Option<(String, String)> { - let trimmed = s.trim().strip_prefix(':').unwrap_or_else(|| s.trim()); - let colon = trimmed.find(':')?; - let (k, v) = trimmed.split_at(colon); - let k = k.trim().to_string(); - let v = v[1..].trim().to_string(); - if k.is_empty() { - return None; - } - Some((k, v)) -} - -fn delegate_prefix(d: &Delegation) -> Option { - for (k, v) in &d.options { - if k == "prefix" { - let trimmed = v.trim(); - return match trimmed { - "true" => Some(d.to.clone()), - "false" | "nil" => None, - // Strip leading `:` (symbol) or surrounding quotes (string). - other => { - let stripped = other - .strip_prefix(':') - .or_else(|| other.strip_prefix('"').and_then(|s| s.strip_suffix('"'))) - .or_else(|| other.strip_prefix('\'').and_then(|s| s.strip_suffix('\''))) - .unwrap_or(other); - if stripped.is_empty() { - None - } else { - Some(stripped.to_string()) - } - } - }; - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ir::{ - AssocKind, CppAccess, CppBase, CppField, CppFriend, CppMacroUse, CppMethod, - CppStaticAssert, CppTemplate, CppTemplateKind, Field, Function, Model, - }; - - /// A minimal account_move-shaped graph mirroring the Odoo fixture used - /// in `lance_graph::graph::spo::action_emitter`. - fn fixture() -> ModelGraph { - ModelGraph { - namespace: "odoo".to_string(), - models: vec![Model { - name: "account_move".to_string(), - fields: vec![Field { - name: "amount_total".to_string(), - depends_on: vec!["line_ids.balance".to_string()], - emitted_by: Some("_compute_amount".to_string()), - ..Default::default() - }], - functions: vec![Function { - name: "_compute_amount".to_string(), - reads: vec!["currency_id".to_string()], - raises: vec!["UserError".to_string()], - traverses: vec!["line_ids".to_string()], - ..Default::default() - }], - ..Default::default() - }], - } - } - - #[test] - fn expands_all_predicate_classes() { - let triples = expand(&fixture()); - let has = - |s: &str, p: &str, o: &str| triples.iter().any(|t| t.s == s && t.p == p && t.o == o); - - assert!(has("odoo:account_move", "rdf:type", "ogit:ObjectType")); - assert!(has( - "odoo:account_move.amount_total", - "rdf:type", - "ogit:Property" - )); - // Ownership edge so soc_findings counts the field as a data member. - assert!(has( - "odoo:account_move", - "has_field", - "odoo:account_move.amount_total" - )); - assert!(has( - "odoo:account_move._compute_amount", - "rdf:type", - "ogit:Function" - )); - assert!(has( - "odoo:account_move", - "has_function", - "odoo:account_move._compute_amount" - )); - assert!(has( - "odoo:account_move.amount_total", - "emitted_by", - "odoo:account_move._compute_amount" - )); - assert!(has( - "odoo:account_move.amount_total", - "depends_on", - "odoo:account_move.line_ids.balance" - )); - assert!(has( - "odoo:account_move._compute_amount", - "reads_field", - "odoo:account_move.currency_id" - )); - assert!(has( - "odoo:account_move._compute_amount", - "raises", - "exc:UserError" - )); - assert!(has( - "odoo:account_move._compute_amount", - "traverses_relation", - "odoo:account_move.line_ids" - )); - } - - #[test] - fn output_is_sorted_and_deterministic() { - let a = expand(&fixture()); - let b = expand(&fixture()); - assert_eq!(a, b, "expansion must be deterministic"); - for w in a.windows(2) { - assert!(w[0].key() <= w[1].key(), "triples not sorted by (s,p,o)"); - } - } - - #[test] - fn body_mutation_facts_expand() { - // A command-shape method: writes a field, dispatches lifecycle - // mutators on self and on a relation. The body-pass triage (F17) - // needs these as `writes_field` (IRI object, Authoritative) and - // `calls` (raw "receiver.method" object, Inferred). - let mut g = fixture(); - g.models[0].functions.push(Function { - name: "post".to_string(), - writes: vec!["state".to_string()], - calls: vec!["self.save".to_string(), "line_ids.update_all".to_string()], - ..Default::default() - }); - let triples = expand(&g); - let has = - |s: &str, p: &str, o: &str| triples.iter().any(|t| t.s == s && t.p == p && t.o == o); - let truth = |p: &str, o: &str| { - triples - .iter() - .find(|t| t.p == p && t.o == o) - .map(|t| (t.f, t.c)) - }; - - // writes_field: object IS an IRI into the model; Authoritative. - assert!(has( - "odoo:account_move.post", - "writes_field", - "odoo:account_move.state" - )); - assert_eq!(truth("writes_field", "odoo:account_move.state"), Some((0.95, 0.90))); - - // calls: object is the raw "receiver.method" string (NOT an IRI); - // Inferred. - assert!(has("odoo:account_move.post", "calls", "self.save")); - assert!(has("odoo:account_move.post", "calls", "line_ids.update_all")); - assert_eq!(truth("calls", "self.save"), Some((0.85, 0.75))); - } - - #[test] - fn duplicate_edges_collapse() { - let mut g = fixture(); - // Push a duplicate depends_on. - g.models[0].fields[0] - .depends_on - .push("line_ids.balance".to_string()); - let triples = expand(&g); - let count = triples - .iter() - .filter(|t| t.p == "depends_on" && t.o == "odoo:account_move.line_ids.balance") - .count(); - assert_eq!(count, 1, "duplicate depends_on must collapse"); - } - - #[test] - fn truth_tiers_are_assigned_per_predicate() { - let triples = expand(&fixture()); - let truth = |p: &str, o: &str| { - triples - .iter() - .find(|t| t.p == p && t.o == o) - .map(|t| (t.f, t.c)) - }; - // Structural - assert_eq!(truth("rdf:type", "ogit:ObjectType"), Some((1.0, 1.0))); - // Authoritative - assert_eq!( - truth("emitted_by", "odoo:account_move._compute_amount"), - Some((0.95, 0.90)) - ); - // Inferred - assert_eq!( - truth("reads_field", "odoo:account_move.currency_id"), - Some((0.85, 0.75)) - ); - } - - #[test] - fn empty_graph_yields_no_triples() { - let g = ModelGraph::new("openproject"); - assert!(expand(&g).is_empty()); - } - - // ────────────────── OpenProject AR-shape tests ────────────────── - - /// A fully-populated WorkPackage-shaped model exercising every AR-shape - /// match arm. This is the [`crate::expand`] half of the - /// `Declaration → Triple` round-trip the council gate asks for. - fn ar_fixture() -> ModelGraph { - let mut wp = Model::new("WorkPackage"); - wp.associations.push(AssocDecl { - kind: AssocKind::BelongsTo, - name: "project".to_string(), - options: vec![("class_name".to_string(), "Project".to_string())], - }); - wp.associations.push(AssocDecl { - kind: AssocKind::HasMany, - name: "time_entries".to_string(), - options: vec![], - }); - wp.validations.push(Validation { - kind: ValidationKind::Validates, - target: "subject".to_string(), - options: vec![ - ("presence".to_string(), "true".to_string()), - // Parametric kind — `length: { maximum: 255 }` — - // the fixture exercises `validation_param` emission - // so the exhaustiveness check sees it. The value is - // in the realistic form `walk::format_hash_inline` - // produces (`:maximum` Sym-prefixed key). - ("length".to_string(), "{:maximum: 255}".to_string()), - ], - }); - wp.validations.push(Validation { - kind: ValidationKind::Normalizes, - target: "email".to_string(), - options: vec![], - }); - wp.callbacks.push(Callback { - phase: "before_save".to_string(), - target: "set_default_status".to_string(), - options: vec![], - }); - wp.concerns.push(ConcernRef { - kind: ConcernKind::Include, - module: "Acts::Customizable".to_string(), - body_ref: None, - }); - wp.concerns.push(ConcernRef { - kind: ConcernKind::Extend, - module: "Pagination::Model".to_string(), - body_ref: None, - }); - wp.concerns.push(ConcernRef { - kind: ConcernKind::Prepend, - module: "Overrides".to_string(), - body_ref: None, - }); - wp.concerns.push(ConcernRef { - kind: ConcernKind::ClassMethodsBlock, - module: "WorkPackage".to_string(), - body_ref: Some("methods.rb:42-58".to_string()), - }); - wp.concerns.push(ConcernRef { - kind: ConcernKind::IncludedBlock, - module: "WorkPackage".to_string(), - body_ref: Some("methods.rb:60-72".to_string()), - }); - wp.attributes.push(AttrDecl { - kind: AttrKind::Attribute, - name: "estimated_hours".to_string(), - options: vec![("type".to_string(), "decimal".to_string())], - }); - wp.attributes.push(AttrDecl { - kind: AttrKind::AttrAccessor, - name: "virtual_flag".to_string(), - options: vec![], - }); - wp.attributes.push(AttrDecl { - kind: AttrKind::AliasAttribute, - name: "title=subject".to_string(), - options: vec![], - }); - wp.attributes.push(AttrDecl { - kind: AttrKind::AliasMethod, - name: "title=subject".to_string(), - options: vec![], - }); - wp.attributes.push(AttrDecl { - kind: AttrKind::UndefMethod, - name: "deprecated_column".to_string(), - options: vec![], - }); - wp.attributes.push(AttrDecl { - kind: AttrKind::Serialize, - name: "preferences".to_string(), - options: vec![("serializer".to_string(), "JSON".to_string())], - }); - wp.delegations.push(Delegation { - methods: vec!["name".to_string(), "identifier".to_string()], - to: "project".to_string(), - options: vec![("prefix".to_string(), "project".to_string())], - }); - wp.scopes.push(ScopeDecl { - kind: ScopeKind::Scope, - name: "open".to_string(), - body_ref: "wp.rb:120".to_string(), - }); - wp.scopes.push(ScopeDecl { - kind: ScopeKind::DefaultScope, - name: String::new(), - body_ref: "wp.rb:130".to_string(), - }); - wp.scopes.push(ScopeDecl { - kind: ScopeKind::Scopes, - name: "by_priority".to_string(), - body_ref: "".to_string(), - }); - wp.acts_as.push(ActsAs { - variant: "list".to_string(), - options: vec![("scope".to_string(), ":project_id".to_string())], - }); - wp.acts_as.push(ActsAs { - variant: "watchable".to_string(), - options: vec![], - }); - wp.dsl_calls.push(DslCall { - name: "register_journal_formatter".to_string(), - args: ":diff,:custom".to_string(), - }); - wp.dsl_calls.push(DslCall { - name: "register_journal_formatted_fields".to_string(), - args: ":subject".to_string(), - }); - wp.dsl_calls.push(DslCall { - name: "activity_provider_for".to_string(), - args: ":work_packages".to_string(), - }); - wp.gem_dsl.push(GemDsl { - gem: GemKind::MountUploader, - args: ":attachments=AttachmentUploader".to_string(), - }); - wp.gem_dsl.push(GemDsl { - gem: GemKind::HasPaperTrail, - args: "on: [:update]".to_string(), - }); - wp.gem_dsl.push(GemDsl { - gem: GemKind::HasClosureTree, - args: String::new(), - }); - wp.gem_dsl.push(GemDsl { - gem: GemKind::CounterCulture, - args: ":project=>:work_packages_count".to_string(), - }); - wp.gem_dsl.push(GemDsl { - gem: GemKind::AutoStripAttributes, - args: ":subject,:description".to_string(), - }); - wp.dynamic_methods.push(DynMethod { - name_expr: ":custom_for_#{field}".to_string(), - body_ref: "wp.rb:200-210".to_string(), - }); - wp.refinements.push(UsingRef { - refinement_module: "OpenProject::DateRange".to_string(), - }); - wp.sti = Some(StiInfo { - inherits_from: Some("Issue".to_string()), - abstract_class: false, - inheritance_column: Some("type".to_string()), - }); - - ModelGraph { - namespace: "openproject".to_string(), - models: vec![wp], - } - } - - #[test] - fn ar_shape_emits_declares_association() { - let triples = expand(&ar_fixture()); - let has = - |s: &str, p: &str, o: &str| triples.iter().any(|t| t.s == s && t.p == p && t.o == o); - assert!(has( - "openproject:WorkPackage", - "declares_association", - "openproject:WorkPackage.project" - )); - assert!(has( - "openproject:WorkPackage", - "declares_association", - "openproject:WorkPackage.time_entries" - )); - } - - /// Every `declares_association` triple gets a sibling - /// `association_kind` triple on the relation IRI naming the Rails - /// macro that declared it. Downstream schema codegen reads this to - /// gate FK-column emission (only `belongs_to` puts a column on the - /// declaring class; `has_many`/`has_one` keep the FK on the other - /// table — without the kind triple, ~57 % of the `OpenProject` - /// corpus's record FKs are phantom). - #[test] - fn ar_shape_emits_association_kind_per_relation() { - let triples = expand(&ar_fixture()); - let has = - |s: &str, p: &str, o: &str| triples.iter().any(|t| t.s == s && t.p == p && t.o == o); - // The fixture declares `belongs_to :project` and - // `has_many :time_entries`. - assert!(has( - "openproject:WorkPackage.project", - "association_kind", - "belongs_to", - )); - assert!(has( - "openproject:WorkPackage.time_entries", - "association_kind", - "has_many", - )); - // The other 3 AssocKind variants — locked via a focused - // fixture below so a future enum change can't silently drop - // the mapping. - } - - /// All 5 `AssocKind` variants map to the documented kind string. - #[test] - fn association_kind_string_table() { - let mut g = ModelGraph::new("openproject"); - let mut m = Model::new("M"); - for (kind, _label) in [ - (AssocKind::BelongsTo, "belongs_to"), - (AssocKind::HasMany, "has_many"), - (AssocKind::HasOne, "has_one"), - (AssocKind::HasAndBelongsToMany, "has_and_belongs_to_many"), - ( - AssocKind::AcceptsNestedAttributesFor, - "accepts_nested_attributes_for", - ), - ] { - m.associations.push(AssocDecl { - kind, - name: format!("{kind:?}").to_lowercase(), - options: vec![], - }); - } - g.models.push(m); - let triples = expand(&g); - let kinds_seen: std::collections::BTreeSet<&str> = triples - .iter() - .filter(|t| t.p == "association_kind") - .map(|t| t.o.as_str()) - .collect(); - for expected in [ - "belongs_to", - "has_many", - "has_one", - "has_and_belongs_to_many", - "accepts_nested_attributes_for", - ] { - assert!( - kinds_seen.contains(expected), - "association_kind `{expected}` missing from emission; saw {kinds_seen:?}", - ); - } - } - - /// **D-AR-5.4** — `class_name:` option override: when an - /// `AssocDecl` carries a `class_name` option, the expander emits - /// a sibling `class_name` triple keyed by the relation IRI. The - /// downstream Schema consumer uses this in preference to the - /// camelcase-singular convention on the relation name. - #[test] - fn ar_shape_emits_class_name_override_when_option_present() { - let mut g = ModelGraph::new("openproject"); - let mut wp = Model::new("WorkPackage"); - // `belongs_to :owner, class_name: 'User'` — the relation - // name `:owner` doesn't follow the Rails convention; the - // override points at `User`. - wp.associations.push(AssocDecl { - kind: AssocKind::BelongsTo, - name: "owner".to_string(), - options: vec![("class_name".to_string(), "User".to_string())], - }); - // A second association WITHOUT class_name — the override must - // be relation-scoped, not model-scoped. - wp.associations.push(AssocDecl { - kind: AssocKind::BelongsTo, - name: "project".to_string(), - options: vec![], - }); - g.models.push(wp); - let triples = expand(&g); - - // The override fires on the `owner` relation. - let has = - |s: &str, p: &str, o: &str| triples.iter().any(|t| t.s == s && t.p == p && t.o == o); - assert!( - has("openproject:WorkPackage.owner", "class_name", "User",), - "class_name override missing for `:owner`", - ); - - // The `project` relation does NOT get a class_name triple - // (absence means convention applies). - let project_class_name_count = triples - .iter() - .filter(|t| t.s == "openproject:WorkPackage.project" && t.p == "class_name") - .count(); - assert_eq!( - project_class_name_count, 0, - "class_name must NOT be emitted when option is absent", - ); - } - - /// **D-AR-5.4 — marker stripping (codex P2 on #18)** — the real - /// Ruby frontend (`ruff_ruby_spo::walk::render_node`) stores - /// option values verbatim, preserving source-level markers: - /// strings keep their `"`/`'` pair, symbols keep their leading - /// `:`. Downstream FK resolvers compare against bare class names - /// like `User`, so all four source forms (`"User"`, `'User'`, - /// `User`, `:User`) must strip down to the bare name before - /// emission. - #[test] - fn ar_shape_strips_class_name_source_markers() { - let mut g = ModelGraph::new("openproject"); - let mut wp = Model::new("WorkPackage"); - // Four variants of `class_name: …` Rails syntax: - // double-quoted, single-quoted, bare const reference, - // and symbol literal — render_node emits each - // verbatim-with-markers. - wp.associations.push(AssocDecl { - kind: AssocKind::BelongsTo, - name: "double_quoted".to_string(), - options: vec![("class_name".to_string(), "\"User\"".to_string())], - }); - wp.associations.push(AssocDecl { - kind: AssocKind::BelongsTo, - name: "single_quoted".to_string(), - options: vec![("class_name".to_string(), "'User'".to_string())], - }); - wp.associations.push(AssocDecl { - kind: AssocKind::BelongsTo, - name: "bare_const".to_string(), - options: vec![("class_name".to_string(), "User".to_string())], - }); - wp.associations.push(AssocDecl { - kind: AssocKind::BelongsTo, - name: "symbol_form".to_string(), - options: vec![("class_name".to_string(), ":User".to_string())], - }); - g.models.push(wp); - let triples = expand(&g); - - // All four lower to the same bare `User` object. - for relation in [ - "double_quoted", - "single_quoted", - "bare_const", - "symbol_form", - ] { - let s = format!("openproject:WorkPackage.{relation}"); - let class_name_objects: Vec<&str> = triples - .iter() - .filter(|t| t.s == s && t.p == "class_name") - .map(|t| t.o.as_str()) - .collect(); - assert_eq!( - class_name_objects, - vec!["User"], - "relation `{relation}` must emit bare `User`; got {class_name_objects:?}", - ); - } - } - - /// Unit-level lock on the marker-stripper — covers each source - /// form independently plus the unmatched-quote edge cases. - #[test] - fn strip_class_name_marker_handles_all_source_forms() { - // String literals: both quote forms. - assert_eq!(strip_class_name_marker("\"User\""), "User"); - assert_eq!(strip_class_name_marker("'User'"), "User"); - // Bare const reference: pass through. - assert_eq!(strip_class_name_marker("User"), "User"); - // Symbol form: leading `:` stripped. - assert_eq!(strip_class_name_marker(":User"), "User"); - // Namespaced (string form): inner namespace preserved; the - // downstream consumer normalises the `::`-prefix. - assert_eq!( - strip_class_name_marker("\"Storages::FileLink\""), - "Storages::FileLink", - ); - // Mismatched / unmatched leading quote: pass through unchanged - // (safer than fabricating semantics from a half-quoted input). - assert_eq!(strip_class_name_marker("\"User"), "\"User"); - assert_eq!(strip_class_name_marker("User\""), "User\""); - assert_eq!(strip_class_name_marker(""), ""); - } - - /// **D-AR-5.8** — Rails `validates :attr, : true` option - /// keys lift to sibling `validation_kind` triples keyed by the - /// attribute IRI. Multiple kinds per declaration emit multiple - /// triples; block-form `validate { ... }` (no option-keys) emits - /// none. - #[test] - fn ar_shape_emits_validation_kind_per_recognised_option() { - let mut g = ModelGraph::new("openproject"); - let mut user = Model::new("User"); - // `validates :email, presence: true, uniqueness: true, format: { with: /.../ }` - user.validations.push(Validation { - kind: ValidationKind::Validates, - target: "email".to_string(), - options: vec![ - ("presence".to_string(), "true".to_string()), - ("uniqueness".to_string(), "true".to_string()), - ("format".to_string(), "/.../".to_string()), - ], - }); - // `validates :age, numericality: true` - user.validations.push(Validation { - kind: ValidationKind::Validates, - target: "age".to_string(), - options: vec![("numericality".to_string(), "true".to_string())], - }); - // Block-form `validate :method` — no option-keys, emits zero - // validation_kind triples (the existence-of-validation - // `validates_constraint` triple still fires). - user.validations.push(Validation { - kind: ValidationKind::Validate, - target: "custom_check".to_string(), - options: vec![], - }); - // Unknown option key (`if:`, `allow_nil:`, etc.) — pass - // through silently, no validation_kind emitted. - user.validations.push(Validation { - kind: ValidationKind::Validates, - target: "title".to_string(), - options: vec![("if".to_string(), ":published?".to_string())], - }); - g.models.push(user); - - let triples = expand(&g); - let kinds_for = |attr_iri: &str| -> BTreeSet<&str> { - triples - .iter() - .filter(|t| t.s == attr_iri && t.p == "validation_kind") - .map(|t| t.o.as_str()) - .collect() - }; - - assert_eq!( - kinds_for("openproject:User.email"), - BTreeSet::from(["presence", "uniqueness", "format"]), - "email must emit all three recognised kinds", - ); - assert_eq!( - kinds_for("openproject:User.age"), - BTreeSet::from(["numericality"]), - ); - // Block-form validate emits no validation_kind triples. - assert!( - kinds_for("openproject:User.custom_check").is_empty(), - "block-form validate must not emit validation_kind", - ); - // Unknown options also emit none. - assert!( - kinds_for("openproject:User.title").is_empty(), - "non-recognised option keys must not emit validation_kind", - ); - - // The existence-of-validation `validates_constraint` triple - // still fires for ALL four declarations (backward compat). - let validates_targets: Vec<&str> = triples - .iter() - .filter(|t| t.p == "validates_constraint") - .map(|t| t.o.as_str()) - .collect(); - for target in ["email", "age", "custom_check", "title"] { - assert!( - validates_targets.contains(&target), - "validates_constraint must fire for `{target}` (backward compat)", - ); - } - } - - /// Unit-level lock on the validation-kind extractor — recognised - /// keys preserve declaration order; unrecognised non-gating keys - /// (e.g. `message:`) pass through silently. - #[test] - fn extract_validation_kinds_recognises_canonical_rails_set() { - let opts: Vec<(String, String)> = [ - ("presence", "true"), - ("message", "\"required\""), - ("uniqueness", "true"), - ("length", "{maximum: 255}"), - ("format", "/.../"), - ] - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - assert_eq!( - extract_validation_kinds(&opts), - vec!["presence", "uniqueness", "length", "format"], - ); - // Empty options → empty. - assert!(extract_validation_kinds(&[]).is_empty()); - // Only non-recognised (no kind keys) → empty. - let only_msg: Vec<(String, String)> = [("message", "\"oops\"")] - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - assert!(extract_validation_kinds(&only_msg).is_empty()); - } - - /// **D-AR-5.8 — gating suppression (codex P2 on #21)** — when - /// the validation carries any gating option (`if`, `unless`, - /// `on`, `allow_nil`, `allow_blank`), `extract_validation_kinds` - /// returns an empty Vec. The schema consumer falls back to the - /// catch-all presence behaviour from the `validates_constraint` - /// triple — safer than over-enforcing a conditional constraint. - #[test] - fn extract_validation_kinds_suppresses_on_gating_options() { - let mk = |opts: &[(&str, &str)]| -> Vec<(String, String)> { - opts.iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - }; - // Each gating option, individually, must suppress emission. - for gating in ["if", "unless", "on", "allow_nil", "allow_blank"] { - let opts = mk(&[ - ("presence", "true"), - ("numericality", "true"), - (gating, ":anything"), - ]); - assert!( - extract_validation_kinds(&opts).is_empty(), - "gating option `{gating}` must suppress kind emission", - ); - } - } - - /// **D-AR-5.8 — falsy validator skip (codex P2 on #21)** — - /// `validates :foo, presence: false` / `presence: nil` is a no-op - /// in Rails; emitting a `validation_kind` triple for it would - /// invent a schema constraint where Rails has none. - #[test] - fn extract_validation_kinds_skips_falsy_validator_values() { - let mk = |opts: &[(&str, &str)]| -> Vec<(String, String)> { - opts.iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - }; - // `presence: false` is a no-op; `uniqueness: true` still - // fires. - let opts = mk(&[("presence", "false"), ("uniqueness", "true")]); - assert_eq!(extract_validation_kinds(&opts), vec!["uniqueness"]); - // `nil` value, same treatment. - let opts = mk(&[("presence", "nil"), ("numericality", "true")]); - assert_eq!(extract_validation_kinds(&opts), vec!["numericality"]); - // All falsy → empty. - let opts = mk(&[("presence", "false"), ("uniqueness", "nil")]); - assert!(extract_validation_kinds(&opts).is_empty()); - } - - /// **D-AR-5.8 — additional Rails kinds (codex P2 on #21)** — - /// the recognised set now includes `absence` (inverse of - /// presence) and `comparison` (`greater_than` / `less_than` family). - /// Both lift to `validation_kind` triples the downstream - /// consumer can act on. - #[test] - fn extract_validation_kinds_recognises_absence_and_comparison() { - let mk = |opts: &[(&str, &str)]| -> Vec<(String, String)> { - opts.iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - }; - // `validates :archived_at, absence: true` - assert_eq!( - extract_validation_kinds(&mk(&[("absence", "true")])), - vec!["absence"], - ); - // `validates :end_date, comparison: { greater_than: :start_date }` - assert_eq!( - extract_validation_kinds(&mk(&[("comparison", "{greater_than: :start_date}")])), - vec!["comparison"], - ); - // Combined with other kinds. - assert_eq!( - extract_validation_kinds(&mk(&[("presence", "true"), ("absence", "true"),])), - vec!["presence", "absence"], - ); - } - - /// **D-AR-5.11** — `extract_hash_options` parses the - /// verbatim Ruby hash-literal form produced by - /// `walk::format_hash_inline` into `(key, value)` pairs. - #[test] - fn extract_hash_options_parses_inline_hash_literals() { - // Single pair — symbol-key form (`{maximum: 255}` after - // render_node prefixes `:` on the Sym key, the helper - // strips it). This is what `walk::format_hash_inline` - // actually produces for `validates :name, - // length: { maximum: 255 }`. - assert_eq!( - extract_hash_options("{:maximum: 255}"), - vec![("maximum".to_string(), "255".to_string())], - ); - // Bare form (no Sym prefix) — defensive: passes through - // unchanged if it ever appears. - assert_eq!( - extract_hash_options("{maximum: 255}"), - vec![("maximum".to_string(), "255".to_string())], - ); - // Multiple pairs, comma-separated. Symbol-prefixed keys - // (the realistic Ruby-frontend shape). - assert_eq!( - extract_hash_options("{:maximum: 255, :minimum: 3}"), - vec![ - ("maximum".to_string(), "255".to_string()), - ("minimum".to_string(), "3".to_string()), - ], - ); - // Symbol values keep their leading colon (consumer normalises). - assert_eq!( - extract_hash_options("{:greater_than: :start_date}"), - vec![("greater_than".to_string(), ":start_date".to_string())], - ); - // Nested hashes / arrays: the OUTER comma split tracks depth, - // so commas inside nested `{}` / `[]` don't split the pair. - assert_eq!( - extract_hash_options("{:in: [1,2,3]}"), - vec![("in".to_string(), "[1,2,3]".to_string())], - ); - assert_eq!( - extract_hash_options("{:nested: {:a: 1, :b: 2}, :other: 3}"), - vec![ - ("nested".to_string(), "{:a: 1, :b: 2}".to_string()), - ("other".to_string(), "3".to_string()), - ], - ); - // Empty hash. - assert_eq!(extract_hash_options("{}"), Vec::<(String, String)>::new()); - // Non-hash (no `{}` delimiters) → empty (boolean/nil/array - // option values don't carry inner-hash params). - assert_eq!(extract_hash_options("true"), Vec::<(String, String)>::new()); - assert_eq!(extract_hash_options("nil"), Vec::<(String, String)>::new()); - assert_eq!(extract_hash_options(""), Vec::<(String, String)>::new()); - } - - /// **D-AR-5.11** — parametric `validates :foo, length: { maximum: N }` - /// emits sibling `validation_param` triples per inner-hash key. - /// Non-hash kinds (`presence: true`) emit zero `validation_param` - /// triples — the existing `validation_kind` covers them already. - #[test] - fn ar_shape_emits_validation_param_per_inner_hash_key() { - let mut g = ModelGraph::new("openproject"); - let mut user = Model::new("User"); - // `validates :name, length: { maximum: 256, minimum: 3 }` - // — option value is in the realistic form - // `walk::format_hash_inline` emits (symbol-prefixed keys). - user.validations.push(Validation { - kind: ValidationKind::Validates, - target: "name".to_string(), - options: vec![( - "length".to_string(), - "{:maximum: 256, :minimum: 3}".to_string(), - )], - }); - // `validates :age, numericality: { greater_than: 0 }` - user.validations.push(Validation { - kind: ValidationKind::Validates, - target: "age".to_string(), - options: vec![("numericality".to_string(), "{:greater_than: 0}".to_string())], - }); - // Non-hash kind — no validation_param triples. - user.validations.push(Validation { - kind: ValidationKind::Validates, - target: "email".to_string(), - options: vec![("presence".to_string(), "true".to_string())], - }); - g.models.push(user); - - let triples = expand(&g); - let params_for = |attr_iri: &str| -> BTreeSet<&str> { - triples - .iter() - .filter(|t| t.s == attr_iri && t.p == "validation_param") - .map(|t| t.o.as_str()) - .collect() - }; - - assert_eq!( - params_for("openproject:User.name"), - BTreeSet::from(["length:maximum=256", "length:minimum=3"]), - "name must emit two `length:...` params", - ); - assert_eq!( - params_for("openproject:User.age"), - BTreeSet::from(["numericality:greater_than=0"]), - ); - // Non-hash kind: no params. - assert!( - params_for("openproject:User.email").is_empty(), - "presence: true is non-hash → no validation_param triples", - ); - - // validation_kind triples still fire for ALL three (backward - // compat — params are additive). - let kinds_for = |attr_iri: &str| -> BTreeSet<&str> { - triples - .iter() - .filter(|t| t.s == attr_iri && t.p == "validation_kind") - .map(|t| t.o.as_str()) - .collect() - }; - assert_eq!( - kinds_for("openproject:User.name"), - BTreeSet::from(["length"]) - ); - assert_eq!( - kinds_for("openproject:User.age"), - BTreeSet::from(["numericality"]), - ); - assert_eq!( - kinds_for("openproject:User.email"), - BTreeSet::from(["presence"]), - ); - } - - #[test] - fn ar_shape_emits_validates_and_normalizes() { - let triples = expand(&ar_fixture()); - assert!( - triples - .iter() - .any(|t| t.p == "validates_constraint" && t.o == "subject") - ); - assert!( - triples - .iter() - .any(|t| t.p == "normalizes_attribute" && t.o == "email") - ); - } - - #[test] - fn ar_shape_emits_callback_with_phase_in_object() { - let triples = expand(&ar_fixture()); - assert!( - triples - .iter() - .any(|t| t.p == "has_callback" && t.o == "before_save:set_default_status") - ); - } - - #[test] - fn ar_shape_emits_concern_composition_per_kind() { - let triples = expand(&ar_fixture()); - let has = |p: &str, o: &str| triples.iter().any(|t| t.p == p && t.o == o); - assert!(has("includes_module", "Acts::Customizable")); - assert!(has("extends_module", "Pagination::Model")); - assert!(has("prepends_module", "Overrides")); - assert!(has("concern_class_methods", "methods.rb:42-58")); - assert!(has("concern_included_block", "methods.rb:60-72")); - } - - #[test] - fn ar_shape_concern_blocks_carry_structural_truth() { - let triples = expand(&ar_fixture()); - let truth = |p: &str| { - triples - .iter() - .find(|t| t.p == p) - .map(|t| (t.f, t.c)) - .unwrap() - }; - assert_eq!(truth("concern_class_methods"), (1.0, 1.0)); - assert_eq!(truth("concern_included_block"), (1.0, 1.0)); - } - - #[test] - fn ar_shape_emits_attributes_per_kind() { - let triples = expand(&ar_fixture()); - let has = |p: &str, o: &str| triples.iter().any(|t| t.p == p && t.o == o); - assert!(has("has_attribute", "estimated_hours")); - assert!(has("has_attribute", "virtual_flag")); - assert!(has("has_attribute", "preferences")); - assert!(has("aliases_attribute", "title=subject")); - assert!(has("aliases_method", "title=subject")); - assert!(has("column_override", "deprecated_column")); - } - - #[test] - fn ar_shape_expands_delegation_to_one_triple_per_method() { - // The fixture's `delegate :name, :identifier, to: :project, prefix: "project"` - // exposes `project_name` / `project_identifier` (NOT `name` / - // `identifier`), per Rails' `prefix:` semantics. Codex P2 PR #5 - // — the expander now honours the prefix option. - let triples = expand(&ar_fixture()); - assert!( - triples - .iter() - .any(|t| t.p == "delegates_to" && t.o == "project_name=>via:project"), - "delegate :name + prefix: \"project\" → exposes project_name" - ); - assert!( - triples - .iter() - .any(|t| t.p == "delegates_to" && t.o == "project_identifier=>via:project") - ); - // The un-prefixed forms must NOT appear (the original methods - // do not exist on the caller class). - assert!( - !triples - .iter() - .any(|t| t.p == "delegates_to" && t.o == "name=>via:project"), - "un-prefixed name must NOT appear when prefix: is set", - ); - } - - /// **Codex P2 regression (PR #5 r…)** — verify each `prefix:` shape - /// (true / symbol / false / absent) maps to the correct exposed - /// method name. - #[test] - fn delegate_prefix_option_shapes() { - let cases = [ - // (prefix-option-value, expected-prefix-or-none) - ("true", Some("project".to_string())), // prefix: true → use `to` - (":owner", Some("owner".to_string())), // prefix: :owner - ("\"owner\"", Some("owner".to_string())), // prefix: "owner" - ("false", None), // prefix: false → no rename - ("nil", None), - ]; - for (opt_value, expected) in cases { - let d = crate::ir::Delegation { - methods: vec!["name".to_string()], - to: "project".to_string(), - options: vec![("prefix".to_string(), opt_value.to_string())], - }; - assert_eq!( - delegate_prefix(&d), - expected, - "prefix: {opt_value} should yield {expected:?}", - ); - } - // Absent prefix → None. - let d = crate::ir::Delegation { - methods: vec!["name".to_string()], - to: "project".to_string(), - options: vec![], - }; - assert_eq!(delegate_prefix(&d), None); - } - - #[test] - fn ar_shape_emits_scopes_per_kind() { - let triples = expand(&ar_fixture()); - let has = |p: &str, o: &str| triples.iter().any(|t| t.p == p && t.o == o); - assert!(has("has_scope", "open=wp.rb:120")); - assert!(has("has_default_scope", "wp.rb:130")); - assert!(has("has_scope", "by_priority=")); - } - - #[test] - fn ar_shape_emits_acts_as_with_variant_and_options() { - let triples = expand(&ar_fixture()); - assert!( - triples - .iter() - .any(|t| t.p == "acts_as" && t.o.starts_with("list:")) - ); - assert!( - triples - .iter() - .any(|t| t.p == "acts_as" && t.o == "watchable") - ); - } - - #[test] - fn ar_shape_routes_dsl_calls_by_name() { - let triples = expand(&ar_fixture()); - // Promoted predicates carry just the args (not the name). - assert!( - triples - .iter() - .any(|t| t.p == "registers_journal_formatter" && t.o == ":diff,:custom") - ); - assert!( - triples - .iter() - .any(|t| t.p == "registers_journal_formatted_fields" && t.o == ":subject") - ); - // Catch-all carries name(args). - assert!( - triples - .iter() - .any(|t| t.p == "has_dsl_call" && t.o == "activity_provider_for(:work_packages)") - ); - } - - #[test] - fn ar_shape_emits_gem_dsl_per_gem() { - let triples = expand(&ar_fixture()); - assert!(triples.iter().any(|t| t.p == "mounts_uploader")); - assert!(triples.iter().any(|t| t.p == "has_paper_trail")); - assert!(triples.iter().any(|t| t.p == "has_closure_tree")); - assert!(triples.iter().any(|t| t.p == "counter_cultures")); - assert!(triples.iter().any(|t| t.p == "auto_strips")); - } - - #[test] - fn ar_shape_defines_method_uses_inferred_per_edge() { - let triples = expand(&ar_fixture()); - let t = triples.iter().find(|t| t.p == "defines_method").unwrap(); - assert_eq!((t.f, t.c), (0.85, 0.75)); - } - - #[test] - fn ar_shape_emits_refinement_and_sti() { - let triples = expand(&ar_fixture()); - assert!( - triples - .iter() - .any(|t| t.p == "uses_refinement" && t.o == "OpenProject::DateRange") - ); - // STI → `inherits_from` (NOT `includes_module`) — STI is - // single-table inheritance with a type discriminator column, - // semantically distinct from method-body composition. The - // parent is emitted as a namespaced IRI (`openproject:Issue`) - // so it's joinable to the parent's own `ObjectType` - // declaration (codex P2 on #19; mirrors the C++ `InheritsFrom` - // arm). - assert!( - triples - .iter() - .any(|t| t.p == "inherits_from" && t.o == "openproject:Issue"), - "STI parent must be namespaced; got triples: {:?}", - triples - .iter() - .filter(|t| t.p == "inherits_from") - .collect::>(), - ); - // The STI parent must NOT also appear under `includes_module` - // — that would conflate the two semantics. - assert!( - !triples - .iter() - .any(|t| t.p == "includes_module" - && (t.o == "Issue" || t.o == "openproject:Issue")), - "STI parent must not double-emit as includes_module", - ); - } - - #[test] - fn ar_shape_op_extracted_triples_carry_calibrated_truth() { - let triples = expand(&ar_fixture()); - // declares_association uses OpenProjectExtracted - let t = triples - .iter() - .find(|t| t.p == "declares_association") - .unwrap(); - assert_eq!((t.f, t.c), (0.95, 0.88)); - } - - /// Coverage proof for D-AR-2: every predicate the AR-shape declares - /// fires from this fixture. The expansion covers all of the new - /// predicates except `reads_field` and `traverses_relation` (those - /// are part of the core 7 and need a populated `Function` IR which - /// this fixture intentionally omits). - #[test] - fn ar_shape_emits_every_ar_predicate() { - let triples = expand(&ar_fixture()); - let predicates_seen: BTreeSet<&str> = triples.iter().map(|t| t.p.as_str()).collect(); - for p in [ - // OpenProjectExtracted defaults - "declares_association", - "validates_constraint", - "normalizes_attribute", - "has_callback", - "includes_module", - "extends_module", - "prepends_module", - "has_attribute", - "aliases_attribute", - "aliases_method", - "column_override", - "delegates_to", - "has_scope", - "has_default_scope", - "acts_as", - "registers_journal_formatter", - "registers_journal_formatted_fields", - "has_dsl_call", - "mounts_uploader", - "has_paper_trail", - "has_closure_tree", - "counter_cultures", - "auto_strips", - "uses_refinement", - "association_kind", - "class_name", - "validation_kind", - "validation_param", - // Inferred (per-edge override) - "defines_method", - // Structural (block markers) - "concern_class_methods", - "concern_included_block", - ] { - assert!( - predicates_seen.contains(p), - "AR-shape predicate `{p}` was not emitted by the fixture — \ - D-AR-2 expand match arm missing", - ); - } - } - - // ────────────────── C++ machine-plane tests ────────────────── - - /// A `Tesseract::Recognizer`-shaped model exercising every C++ match - /// arm. The expand half of the `CppClass → Triple` round-trip the - /// `ruff_cpp_spo` locked-shape test mirrors. - fn cpp_fixture() -> ModelGraph { - let mut rec = Model::new("Tesseract::Recognizer"); - rec.bases.push(CppBase { - name: "Tesseract::Classify".to_string(), - access: CppAccess::Public, - virtual_base: false, - }); - rec.member_fields.push(CppField { - name: "recognizer_".to_string(), - type_name: "std::unique_ptr".to_string(), - }); - rec.methods.push(CppMethod { - name: "Recognize".to_string(), - is_pure_virtual: false, - constexpr_kind: None, - is_noexcept: true, - overrides: Some("Tesseract::Classify.Recognize(int,const Image &) const".to_string()), - operator_kind: None, - requires_clause: None, - return_type: Some("int".to_string()), - param_types: vec!["int".to_string(), "const Image &".to_string()], - is_const: true, - is_static: false, - access: CppAccess::Public, - }); - rec.methods.push(CppMethod { - name: "Clear".to_string(), - is_pure_virtual: true, - constexpr_kind: None, - is_noexcept: false, - overrides: None, - operator_kind: None, - requires_clause: None, - return_type: None, - param_types: Vec::new(), - is_const: false, - is_static: false, - access: CppAccess::Public, - }); - rec.methods.push(CppMethod { - name: "kMaxRating".to_string(), - is_pure_virtual: false, - constexpr_kind: Some(ConstexprKind::Constexpr), - is_noexcept: false, - overrides: None, - operator_kind: None, - requires_clause: None, - return_type: None, - param_types: Vec::new(), - is_const: false, - is_static: true, - access: CppAccess::Public, - }); - rec.methods.push(CppMethod { - name: "operator==".to_string(), - is_pure_virtual: false, - constexpr_kind: None, - is_noexcept: false, - overrides: None, - operator_kind: Some("operator==".to_string()), - requires_clause: Some("std::equality_comparable".to_string()), - return_type: None, - param_types: Vec::new(), - is_const: false, - is_static: false, - access: CppAccess::Public, - }); - rec.templates.push(CppTemplate { - kind: CppTemplateKind::Specialisation, - name: "GenericVector".to_string(), - }); - rec.templates.push(CppTemplate { - kind: CppTemplateKind::Instantiation, - name: "GenericVector".to_string(), - }); - rec.friends.push(CppFriend { - name: "TessdataManager".to_string(), - }); - rec.macro_uses.push(CppMacroUse { - identifier: "BOOL_MEMBER".to_string(), - macro_name: "INT_MEMBER".to_string(), - }); - rec.static_asserts.push(CppStaticAssert { - condition: "sizeof(int) == 4".to_string(), - }); - ModelGraph { - namespace: "cpp".to_string(), - models: vec![rec], - } - } - - #[test] - fn cpp_classifies_class_fields_and_methods() { - let triples = expand(&cpp_fixture()); - let has = - |s: &str, p: &str, o: &str| triples.iter().any(|t| t.s == s && t.p == p && t.o == o); - // Class + member + method classification (reuses the core arms). - assert!(has( - "cpp:Tesseract::Recognizer", - "rdf:type", - "ogit:ObjectType" - )); - assert!(has( - "cpp:Tesseract::Recognizer.recognizer_", - "rdf:type", - "ogit:Property" - )); - assert!(has( - "cpp:Tesseract::Recognizer.Recognize(int,const Image &) const", - "rdf:type", - "ogit:Function" - )); - assert!(has( - "cpp:Tesseract::Recognizer", - "has_function", - "cpp:Tesseract::Recognizer.Recognize(int,const Image &) const" - )); - assert!(has( - "cpp:Tesseract::Recognizer", - "has_field", - "cpp:Tesseract::Recognizer.recognizer_" - )); - } - - #[test] - fn cpp_emits_inheritance_as_clean_base_iri() { - let triples = expand(&cpp_fixture()); - assert!(triples.iter().any(|t| { - t.s == "cpp:Tesseract::Recognizer" - && t.p == "inherits_from" - && t.o == "cpp:Tesseract::Classify" - })); - } - - #[test] - fn cpp_emits_every_method_property_predicate() { - let triples = expand(&cpp_fixture()); - let has = |p: &str, o: &str| triples.iter().any(|t| t.p == p && t.o == o); - assert!(has("is_noexcept", "true")); - assert!(has("is_pure_virtual", "true")); - assert!(has("is_constexpr", "constexpr")); - assert!(has( - "virtually_overrides", - "cpp:Tesseract::Classify.Recognize(int,const Image &) const" - )); - assert!(has("defines_operator", "operator==")); - assert!(has("requires_concept", "std::equality_comparable")); - // AST-DLL signature shape: return type + ordered (index-prefixed) params. - assert!(has("returns_type", "int")); - assert!(has("has_param_type", "0:int")); - assert!(has("has_param_type", "1:const Image &")); - // ORM-downcast shape: const (read accessor) + static (class-level). - assert!(has("is_const", "true")); - assert!(has("is_static", "true")); - // Access specifier (fixture methods are all public). - assert!(has("has_visibility", "public")); - } - - #[test] - fn cpp_emits_templates_friends_macros_static_asserts() { - let triples = expand(&cpp_fixture()); - let has = |p: &str, o: &str| triples.iter().any(|t| t.p == p && t.o == o); - assert!(has("template_specialises", "GenericVector")); - assert!(has("template_instantiates", "GenericVector")); - assert!(has("is_friend_of", "TessdataManager")); - assert!(has("uses_macro_expansion", "BOOL_MEMBER<=INT_MEMBER")); - assert!(has("static_asserts", "sizeof(int) == 4")); - } - - #[test] - fn cpp_truth_tiers_match_calibration() { - let triples = expand(&cpp_fixture()); - let truth = |p: &str| triples.iter().find(|t| t.p == p).map(|t| (t.f, t.c)); - // CppExtracted default - assert_eq!(truth("inherits_from"), Some((0.95, 0.82))); - assert_eq!(truth("has_field"), Some((0.95, 0.82))); - // Structural per-edge override - assert_eq!(truth("is_friend_of"), Some((1.0, 1.0))); - // Inferred per-edge overrides - assert_eq!(truth("uses_macro_expansion"), Some((0.85, 0.75))); - assert_eq!(truth("template_instantiates"), Some((0.85, 0.75))); - } - - /// Every C++ machine-plane predicate fires from the fixture — the C++ - /// analog of `ar_shape_emits_every_ar_predicate`. - #[test] - fn cpp_emits_every_cpp_predicate() { - let triples = expand(&cpp_fixture()); - let seen: BTreeSet<&str> = triples.iter().map(|t| t.p.as_str()).collect(); - for p in [ - "inherits_from", - "has_field", - "template_specialises", - "template_instantiates", - "virtually_overrides", - "is_friend_of", - "defines_operator", - "uses_macro_expansion", - "is_pure_virtual", - "is_constexpr", - "is_noexcept", - "requires_concept", - "static_asserts", - "returns_type", - "has_param_type", - "is_const", - "is_static", - "has_visibility", - ] { - assert!( - seen.contains(p), - "C++ predicate `{p}` was not emitted by the fixture — expand arm missing", - ); - } - } - - /// The Python/Ruby fixtures must emit ZERO C++ predicates — the C++ - /// sibling Vecs default empty, so no cross-language leakage occurs. - /// - /// Note: `inherits_from` and `has_field` are intentionally - /// cross-frontend, so both are excluded from the guard list below. - /// `inherits_from` — both C++ class inheritance and Rails STI emit it. - /// `has_field` — the model→field ownership edge is now emitted by the - /// core-7 field loop (Odoo/Rails) as well as the C++ `cpp_field` path, - /// so `soc` can count core-7 fields as data members (it landed in the - /// C++ machine-plane block first, hence the historical naming). - #[test] - fn non_cpp_fixtures_emit_no_cpp_only_predicates() { - let cpp_only_predicates = [ - "template_specialises", - "template_instantiates", - "virtually_overrides", - "is_friend_of", - "defines_operator", - "uses_macro_expansion", - "is_pure_virtual", - "is_constexpr", - "is_noexcept", - "requires_concept", - "static_asserts", - "returns_type", - "has_param_type", - "is_const", - "is_static", - ]; - for graph in [fixture(), ar_fixture()] { - let triples = expand(&graph); - for t in &triples { - assert!( - !cpp_only_predicates.contains(&t.p.as_str()), - "non-C++ fixture leaked C++-only predicate `{}`", - t.p, - ); - } - } - } - - /// **D-AR-5.2** — when an `AttrDecl` carries a type annotation in - /// its `options` (key="type"), the expander emits a companion - /// `field_type` triple alongside the `has_attribute` one. The - /// triple's subject is the field IRI (`ns:model.field`) so the - /// downstream Schema consumer can apply the type to the right - /// `FieldDefinition`. - #[test] - fn ar_shape_emits_field_type_for_typed_attribute() { - let mut g = ModelGraph::new("openproject"); - let mut wp = Model::new("WorkPackage"); - wp.attributes.push(AttrDecl { - kind: AttrKind::Attribute, - name: "estimated_hours".to_string(), - options: vec![("type".to_string(), "decimal".to_string())], - }); - wp.attributes.push(AttrDecl { - kind: AttrKind::Attribute, - name: "subject".to_string(), - options: vec![], // no type annotation - }); - g.models.push(wp); - let triples = expand(&g); - let has = - |s: &str, p: &str, o: &str| triples.iter().any(|t| t.s == s && t.p == p && t.o == o); - assert!(has( - "openproject:WorkPackage", - "has_attribute", - "estimated_hours" - )); - assert!(has( - "openproject:WorkPackage.estimated_hours", - "field_type", - "decimal" - )); - // No `field_type` triple for the untyped attribute. - assert!( - !triples - .iter() - .any(|t| t.p == "field_type" && t.o.contains("subject")), - "untyped attribute must not emit field_type", - ); - } -} diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/ir.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/ir.rs deleted file mode 100644 index 1c194ec..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/ir.rs +++ /dev/null @@ -1,766 +0,0 @@ -//! The language-agnostic intermediate representation. -//! -//! A frontend's ONLY job is to fill a [`ModelGraph`] from its own AST. The -//! Python/Odoo frontend reads `@api.depends`, compute-method bodies, and -//! `raise` statements; a Ruby/Rails frontend reads `ActiveRecord` -//! associations, `validate`/`validates` callbacks, and memoized methods. -//! Both produce the SAME `ModelGraph`, so [`crate::expand`] yields the same -//! triple shape. -//! -//! This IR is intentionally dumb: plain owned data, no behaviour, no -//! parsing. It is the contract seam between "how language X exposes its -//! model graph" and "what the SPO store consumes". -//! -//! # Mapping cheat-sheet (core 7 predicates) -//! -//! | IR field | Odoo (Python) | Rails (Ruby) | -//! | --- | --- | --- | -//! | [`Model::name`] | `_name` / class (`account.move`) | `ActiveRecord` class (`WorkPackage`) | -//! | [`Field::name`] | `fields.X = fields.Type(...)` | DB column / `attribute` / `attr_accessor` | -//! | [`Field::depends_on`] | `@api.depends("a.b.c")` args | `belongs_to`/`has_many` chains a method reads | -//! | [`Field::emitted_by`] | `compute="_compute_x"` | memoized/derived method assigning the attr | -//! | [`Function::name`] | `def _compute_x(self)` | `def compute_x` / instance method | -//! | [`Function::reads`] | attribute reads in body | `self.x` / association reads in body | -//! | [`Function::raises`] | `raise UserError(...)` | `raise`, `errors.add`, `validates` | -//! | [`Function::traverses`] | `for r in self.line_ids:` | `work_package.children.each` | -//! -//! # `OpenProject` AR-shape (Rails class-body DSL — the 13 [`Model`] siblings) -//! -//! The Rails `ActiveRecord` class body is a much richer DSL than what the -//! core 7 covers. The 13 sibling-shape `Vec<…>` fields on [`Model`] hold -//! the structured class-level facts; [`crate::expand`] turns them into -//! the 27 `OpenProject` AR-shape predicates added in `triple.rs`. Each -//! field is a thin owned struct (no behaviour, no derivation) — the -//! frontend fills them and the expander projects them into triples. - -use serde::{Deserialize, Serialize}; - -/// The whole extracted model graph for one source tree. -/// -/// **Schema invariant:** zero new fields here. The IR's growth in the -/// `OpenProject` AR-shape expansion lands inside [`Model`] (13 sibling-shape -/// `Vec<…>` fields), keeping the top-level shape stable for downstream -/// consumers that walk `models` only. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct ModelGraph { - /// The IRI namespace prefix for subjects/objects (e.g. `"odoo"`, - /// `"openproject"`). Subjects become `":."`. - pub namespace: String, - /// Every model/entity in the tree. - pub models: Vec, -} - -impl ModelGraph { - /// Create an empty graph for the given namespace. - #[must_use] - pub fn new(namespace: impl Into) -> Self { - Self { - namespace: namespace.into(), - models: Vec::new(), - } - } -} - -/// One model / entity (Odoo model, Rails `ActiveRecord` class). -/// -/// The first three fields ([`Self::name`] / [`Self::fields`] / -/// [`Self::functions`]) are the **core** shape — what both the Odoo and -/// Rails frontends fill, and what the original 7 predicates expand. -/// -/// The remaining 13 fields are the **`OpenProject` AR-shape** — populated -/// only by the Rails frontend (`ruff_ruby_spo`). The Odoo frontend -/// leaves them at their `Default::default()` empty values; the -/// [`crate::expand`] function silently emits no triples for empty -/// collections, so the Python pipeline is unaffected. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct Model { - /// The model identity — kept exactly as the source names it, except - /// that dots in Odoo model names (`account.move`) are normalised to - /// underscores by convention so the IRI dot is unambiguously the - /// model↔member separator. The frontend owns this normalisation. - pub name: String, - /// Fields / attributes / columns. - pub fields: Vec, - /// Methods / functions. - pub functions: Vec, - - /// Frontend-agnostic prototype/delegation inheritance — the parent - /// models this model `inherits_from` (Odoo `_inherit`, and any future - /// language's plain "extends ``"). Names are already - /// frontend-normalised (dot→underscore); the expander emits - /// `(ns:model, inherits_from, ns:parent)` per entry with - /// [`Provenance::Authoritative`]. Distinct from `bases` (C++ base - /// classes, `CppExtracted`) and `sti` (single-parent Rails STI): a - /// multi-parent list carrying no per-parent metadata. Self-references - /// (an Odoo reopen where the sole `_inherit` == the model name) are - /// excluded by the frontend, so this never emits a `model inherits_from - /// model` self-edge. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub inherits: Vec, - - // ───── OpenProject AR-shape: 12 Vec + 1 Option ───── - /// Class-level association declarations (`belongs_to`, `has_many`, - /// `has_one`, `has_and_belongs_to_many`, `accepts_nested_attributes_for`). - /// Expanded as `declares_association` (`OpenProjectExtracted`) per entry. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub associations: Vec, - /// `validates` / `validate` / `normalizes` / `validates_associated` / - /// `validates_each` declarations. Expanded as `validates_constraint` - /// (and `normalizes_attribute` for the `normalizes` variant). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub validations: Vec, - /// Callback declarations (`before_save`, `after_create`, …). Expanded - /// as `has_callback` with the phase encoded in the object slot. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub callbacks: Vec, - /// Concern / module composition references (`include`, `extend`, - /// `prepend`, `class_methods do`, `included do`). Expanded as - /// `includes_module` / `extends_module` / `prepends_module` / - /// `concern_class_methods` / `concern_included_block`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub concerns: Vec, - /// Attribute declarations beyond the schema baseline (`attribute`, - /// `attr_accessor`, `attr_reader`, `attr_readonly`, `alias_attribute`, - /// `alias_method`, `alias`, `undef_method`, `serialize`, `enum`, - /// `store_attribute`, `store_accessor`, `define_attribute_method`). - /// Expanded as `has_attribute` / `aliases_attribute` / `aliases_method` / - /// `column_override` per kind. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attributes: Vec, - /// `delegate :foo, :bar, to: :baz`. Expanded as `delegates_to` — - /// one triple per (method, to) pair. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub delegations: Vec, - /// `scope` / `default_scope` / `scopes` (OP plural). Expanded as - /// `has_scope` / `has_default_scope` with the lambda body kept as - /// a body-source ref (per existing `scope` precedent). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub scopes: Vec, - /// `OpenProject` `acts_as_*` family declarations. Expanded as `acts_as` - /// with the variant + options in the object slot. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub acts_as: Vec, - /// `OpenProject` custom DSL registrations (`register_journal_formatter`, - /// `register_journal_formatted_fields`, plus the long-tail singletons). - /// Expanded as `registers_journal_formatter` / - /// `registers_journal_formatted_fields` (the two promoted predicates) - /// or `has_dsl_call` (catch-all) per name. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub dsl_calls: Vec, - /// Third-party gem DSL (`mount_uploader`, `has_paper_trail`, - /// `has_closure_tree`, `counter_culture`, `auto_strip_attributes`). - /// Expanded as `mounts_uploader` / `has_paper_trail` / `has_closure_tree` / - /// `counter_cultures` / `auto_strips` per gem. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub gem_dsl: Vec, - /// `define_method` sites. Expanded as `defines_method` with - /// [`crate::Provenance::Inferred`] (per-edge override on top of the - /// predicate's `Inferred` default — dynamic by definition). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub dynamic_methods: Vec, - /// `using Refinement` declarations (2 sites in the `OpenProject` corpus). - /// Expanded as `uses_refinement`. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub refinements: Vec, - /// Single-Table Inheritance metadata. `None` for non-STI classes. - /// Currently only the `inherits_from` parent is emitted (as - /// `includes_module`); `abstract_class` + `inheritance_column` are - /// carried for downstream consumers but produce no triples here. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sti: Option, - - // ───── C++ machine-plane: 7 sibling Vecs (filled only by ruff_cpp_spo) ───── - // - // Populated only by the C++ frontend (`ruff_cpp_spo`); the Python/Ruby - // frontends leave them at `Default::default()` empty, and - // `skip_serializing_if` keeps their ndjson byte-identical. The expander - // emits no triples for empty collections, so no other pipeline is - // affected. - /// Base-class declarations (`class X : public Base`). Expanded as - /// `inherits_from` (`CppExtracted`) per base. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub bases: Vec, - /// Data-member declarations. Expanded as `has_field` (`CppExtracted`) - /// plus a structural `(class.field, rdf:type, Property)` classification. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub member_fields: Vec, - /// Method declarations carrying their C++ property flags (virtual / - /// override / pure-virtual / constexpr / noexcept / operator / requires). - /// Each method is classified `(class.method, rdf:type, Function)` + - /// `(class, has_function, class.method)`; the flags expand to the - /// method-property predicates. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub methods: Vec, - /// Template specialisation / instantiation declarations. Expanded as - /// `template_specialises` (`CppExtracted`) / `template_instantiates` - /// (`Inferred`) per kind. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub templates: Vec, - /// `friend class` / `friend fn` declarations. Expanded as `is_friend_of` - /// (Structural). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub friends: Vec, - /// Identifiers originating from preprocessor macro expansion. Expanded - /// as `uses_macro_expansion` (`Inferred`). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub macro_uses: Vec, - /// `static_assert` declarations in class scope. Expanded as - /// `static_asserts` (`CppExtracted`). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub static_asserts: Vec, -} - -/// One field / attribute / column. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct Field { - /// Field name (e.g. `amount_total`). - pub name: String, - /// Declared compute dependencies — dotted relation paths - /// (`line_ids.balance`). Emitted as `depends_on` (Authoritative). - pub depends_on: Vec, - /// The function that computes/writes this field, if any. Emitted as - /// `(field, emitted_by, fn)` (Authoritative). - pub emitted_by: Option, - /// For a relational field, the comodel as the raw dotted Odoo model - /// name (`res.partner`). Emitted as `(field, target, "")` - /// (Authoritative) — the object is the string verbatim, NOT an IRI. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target: Option, - /// For a One2many field, the inverse Many2one field name (`move_id`), - /// raw. Emitted as `(field, inverse_name, "")` (Authoritative). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inverse_name: Option, - /// For a relational field, the Odoo constructor lowercased (`many2one` - /// / `one2many` / `many2many`). Emitted as `(field, relation_kind, - /// "")` (Authoritative). Disambiguates a Many2one (scalar FK) - /// from a Many2many (join table) — both carry only a `target` and no - /// `inverse_name`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relation_kind: Option, - /// For a **non-relational** field, the Odoo constructor lowercased - /// (`char` / `text` / `html` / `integer` / `float` / `monetary` / - /// `boolean` / `date` / `datetime` / `binary` / `selection` / `json` / - /// …). Emitted as `(field, field_type, "")` (Authoritative) — - /// the same `field_type` predicate the Rails `AttrDecl` path uses. Lets - /// a downstream lift upgrade an untyped scalar (`OgScalar`) into a - /// concrete typed wrapper. Mutually exclusive with [`Self::relation_kind`]: - /// relational fields carry their cardinality there, scalars carry their - /// constructor here, so the two predicates never double-emit for one field. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub field_type: Option, - /// For a **DB-column** field (D-AR-3.5 schema stratum: extracted from - /// the Rails migration DSL, `db/migrate/tables/*.rb`), `Some(true)` - /// when the column carries `null: false`. Emitted as - /// `(field, column_not_null, "true")` — only for `Some(true)`; `None` - /// / `Some(false)` emit nothing (nullable is the Rails default, and - /// absence-means-nullable keeps the triple volume proportional to the - /// constraint count). Downstream this is the `required` axis of - /// `DEFINE FIELD` (`TYPE ` vs `TYPE option`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub not_null: Option, -} - -/// One method / function. -/// -/// The first four fields are the original query-shape facts (what the body -/// *reads*). The last two — [`Self::writes`] and [`Self::calls`] — are the -/// **command-shape** facts (what the body *mutates* / *dispatches*), added so -/// the body-pass triage can split a method into query (read-only) vs command -/// (writes state) — the accidentally-imperative-vs-essentially-foreign cut -/// (E-ACCIDENTAL-IMPERATIVE / OGAR F17). Both are `skip_serializing_if`-empty, -/// so a frontend that doesn't populate them (Odoo Python today) leaves the -/// ndjson byte-identical. -#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] -pub struct Function { - /// Function name (e.g. `_compute_amount`). - pub name: String, - /// Field names this function reads in its body. Emitted as - /// `reads_field` (Inferred). - pub reads: Vec, - /// Exception/error type names this function raises. Emitted as - /// `raises` against the `exc:` namespace (Authoritative). - pub raises: Vec, - /// Relation names this function traverses (loop targets). Emitted as - /// `traverses_relation` (Inferred). - pub traverses: Vec, - /// Field names this function WRITES via a `self. = …` setter call - /// in its body. Emitted as `writes_field` (Authoritative — the assignment - /// names its target unambiguously; only the value is uncertain). The - /// command-side counterpart of [`Self::reads`]; together they let the - /// triage classify a method as read-only vs mutating. Plain instance-var - /// assignment (`@x = …`, local memoization) is deliberately NOT a write. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub writes: Vec, - /// Lifecycle-mutator calls the body dispatches, as `"."` - /// (e.g. `self.save`, `order.update`, `line_ids.destroy_all`). Only the - /// closed `ActiveRecord` mutator set is captured (create/update/save/ - /// destroy/…) — not every call — because the signal the body-pass triage - /// needs is "this method calls a writer", i.e. it dispatches a lifecycle - /// verb on some target. Emitted as `calls` (Inferred — receiver - /// resolution is heuristic at static-AST time; the verb itself is a - /// literal). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub calls: Vec, -} - -impl Model { - /// Convenience constructor for a bare model with no members yet. - #[must_use] - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - ..Self::default() - } - } -} - -// ───────────────────────────────────────────────────────────────────────── -// OpenProject AR-shape declarative types -// ───────────────────────────────────────────────────────────────────────── - -/// One class-level association declaration. -/// -/// The Rails frontend emits one of these per `belongs_to` / `has_many` / -/// `has_one` / `has_and_belongs_to_many` / `accepts_nested_attributes_for` -/// macro call. Options are kept as a `(key, value)` list so the -/// 10 nested options (`class_name`, `dependent`, `optional`, `inverse_of`, -/// `through`, `polymorphic`, `foreign_key`, `as`, `source`, `touch`) are -/// represented verbatim without a 10-way enum that would couple the IR -/// to today's option set. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct AssocDecl { - /// The macro that declared this association. - pub kind: AssocKind, - /// The relation symbol (e.g. `project` from `belongs_to :project`). - pub name: String, - /// Nested options, verbatim, in source order. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub options: Vec<(String, String)>, -} - -/// The 5 Rails association macros. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum AssocKind { - /// `belongs_to :rel` - BelongsTo, - /// `has_many :rel` - HasMany, - /// `has_one :rel` - HasOne, - /// `has_and_belongs_to_many :rel` - HasAndBelongsToMany, - /// `accepts_nested_attributes_for :rel` - AcceptsNestedAttributesFor, -} - -/// One validation declaration. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct Validation { - /// The validation macro variant. - pub kind: ValidationKind, - /// Attribute name, method name, or `""` for block-form - /// `validate { … }`. - pub target: String, - /// Validation options (presence / numericality / format / inclusion / - /// length / uniqueness / etc.), verbatim. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub options: Vec<(String, String)>, -} - -/// The 5 Rails validation macros. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ValidationKind { - /// `validates :attr, presence: true` - Validates, - /// `validate :method_name` or `validate { … }` - Validate, - /// `normalizes :attr, with: …` (kept as `ValidationKind` because the - /// frontend collects it alongside validations; the expander emits - /// `normalizes_attribute` distinct from `validates_constraint`). - Normalizes, - /// `validates_associated :rel` - ValidatesAssociated, - /// `validates_each :attr, :attr2 { |record, attr, value| … }` - ValidatesEach, -} - -/// One callback declaration. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct Callback { - /// The callback phase (e.g. `"before_save"`, `"after_create"`, - /// `"around_destroy"`, `"after_destroy_commit"`). Kept as a string - /// because the phase set is 13+ entries and Rails adds more - /// (`after_create_commit`, etc.) — the IR doesn't gate. - pub phase: String, - /// Method symbol or block ref the callback dispatches to. - pub target: String, - /// Conditional options (`if:`, `unless:`, `on:`), verbatim. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub options: Vec<(String, String)>, -} - -/// One concern / module composition reference. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ConcernRef { - /// How the module is composed. - pub kind: ConcernKind, - /// Module name (e.g. `Redmine::Acts::Customizable`). For - /// [`ConcernKind::ClassMethodsBlock`] and - /// [`ConcernKind::IncludedBlock`] this is the *enclosing* concern's - /// own name (the block runs on `self.included` / `class_methods`). - pub module: String, - /// Body source ref for `class_methods do` / `included do` blocks. - /// `None` for ordinary `include` / `extend` / `prepend`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub body_ref: Option, -} - -/// The 5 Rails module-composition forms covered by the AR-shape. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ConcernKind { - /// `include Mod` — mix into instance method namespace. - Include, - /// `extend Mod` — mix into singleton (class) method namespace. - Extend, - /// `prepend Mod` — mix in BEFORE the class itself in MRO. - Prepend, - /// `class_methods do … end` inside a concern. - ClassMethodsBlock, - /// `included do … end` inside a concern. - IncludedBlock, -} - -/// One attribute declaration (non-DB-column). -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct AttrDecl { - /// The declaration macro variant. - pub kind: AttrKind, - /// The attribute name (or `=` for aliases). - pub name: String, - /// Type / serializer / enum-mapping / store-key options, verbatim. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub options: Vec<(String, String)>, -} - -/// The 13 Rails attribute-declaration macros covered by the AR-shape. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum AttrKind { - /// `attribute :x, :type` - Attribute, - /// `attr_accessor :x` - AttrAccessor, - /// `attr_reader :x` - AttrReader, - /// `attr_readonly :x` (Rails read-only column marker) - AttrReadonly, - /// `alias_attribute :new, :orig` (attribute-level alias) - AliasAttribute, - /// `alias_method :new, :orig` (method-level alias, explicit form) - AliasMethod, - /// `alias new orig` (method-level alias, sugar form) - Alias, - /// `undef_method :foo` - UndefMethod, - /// `serialize :data, JSON` - Serialize, - /// `enum :status, { … }` - Enum, - /// `store_attribute :store_key, :attr, :type` - StoreAttribute, - /// `store_accessor :store_key, :attr1, :attr2` - StoreAccessor, - /// `define_attribute_method :attr` (Rails-internal) - DefineAttributeMethod, -} - -/// One `delegate` declaration. A single `delegate :foo, :bar, to: :baz` -/// expands to one [`Delegation`] with `methods = ["foo", "bar"]` and -/// `to = "baz"`; the expander unwinds it into one `delegates_to` triple -/// per method. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct Delegation { - /// Method names being delegated. - pub methods: Vec, - /// The receiver (association name or method symbol). - pub to: String, - /// `prefix:` / `allow_nil:` / `private:`, verbatim. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub options: Vec<(String, String)>, -} - -/// One scope declaration. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ScopeDecl { - /// `scope` / `default_scope` / `scopes` (OP plural form). - pub kind: ScopeKind, - /// Scope name (empty string for `default_scope`). - pub name: String, - /// Lambda body source ref, kept verbatim per the existing - /// `Function::reads` "preserve body shape" precedent. - pub body_ref: String, -} - -/// The 3 Rails scope-declaration macros covered by the AR-shape. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ScopeKind { - /// `scope :name, -> { … }` - Scope, - /// `default_scope -> { … }` - DefaultScope, - /// `scopes :name1, :name2` — `OpenProject` plural form. - Scopes, -} - -/// One `acts_as_*` declaration. The variant lives in the `name` field -/// (`"list"`, `"attachable"`, `"watchable"`, `"searchable"`, -/// `"journalized"`, `"event"`, `"customizable"`, `"tree"`, -/// `"favoritable"`, `"url"`) — kept as a string because new variants -/// arrive without ontology changes. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ActsAs { - /// The variant suffix (e.g. `"list"` for `acts_as_list`). - pub variant: String, - /// Options to the macro call, verbatim. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub options: Vec<(String, String)>, -} - -/// One `OpenProject` custom DSL call. The frontend writes one of these -/// for every class-body method call that isn't covered by another more -/// specific declaration type; the expander routes by `name` into either -/// a promoted predicate (`registers_journal_formatter`, -/// `registers_journal_formatted_fields`) or the catch-all `has_dsl_call`. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct DslCall { - /// The DSL call name (e.g. `"register_journal_formatter"`). - pub name: String, - /// Args, verbatim, preserved as a single string for queryability. - pub args: String, -} - -/// One third-party gem DSL call. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct GemDsl { - /// Which gem's DSL. - pub gem: GemKind, - /// Args, verbatim. - pub args: String, -} - -/// The 5 third-party gem DSLs covered by the AR-shape. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum GemKind { - /// `CarrierWave`'s `mount_uploader :attr, Class`. - MountUploader, - /// `has_paper_trail` (audit log). - HasPaperTrail, - /// `has_closure_tree` (tree structures). - HasClosureTree, - /// `counter_culture` (denormalised counter columns). - CounterCulture, - /// `auto_strip_attributes` (whitespace strip on assignment). - AutoStripAttributes, -} - -/// One `define_method` dynamic-method site. The default expander -/// emission uses [`crate::Provenance::Inferred`] for these — dynamism -/// makes static identification heuristic by definition. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct DynMethod { - /// The name expression — a literal symbol if the source is - /// `define_method(:foo) { … }`, or an arbitrary Ruby expression for - /// `define_method("dynamic_#{x}") { … }`. - pub name_expr: String, - /// Body source ref. - pub body_ref: String, -} - -/// One `using SomeRefinement` declaration. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct UsingRef { - /// The refinement module name. - pub refinement_module: String, -} - -/// Single-Table Inheritance metadata. Carried on [`Model::sti`] when the -/// class participates in STI. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct StiInfo { - /// `class X < Parent` — the parent class name when not - /// `ApplicationRecord` / `ActiveRecord::Base`. Becomes an - /// `includes_module` triple in the expander. - pub inherits_from: Option, - /// `self.abstract_class = true`. - #[serde(default)] - pub abstract_class: bool, - /// `self.inheritance_column = "type"` — the column STI dispatches - /// on (default `"type"` if not overridden). - pub inheritance_column: Option, -} - -// ───────────────────────────────────────────────────────────────────────── -// C++ machine-plane declarative types (filled only by ruff_cpp_spo) -// ───────────────────────────────────────────────────────────────────────── - -/// One base-class declaration (`class Derived : public Base`). -/// -/// The expander emits `(class, inherits_from, ns:base)` with the access -/// specifier and virtual-inheritance flag carried here on the IR but not -/// in the triple — the object stays a clean base-class IRI for graph -/// traversal (mirroring how [`AssocDecl::kind`] is carried but not emitted). -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct CppBase { - /// Base class name as resolved by the AST (e.g. `Tesseract::Classify`). - pub name: String, - /// `public` / `protected` / `private` inheritance. - pub access: CppAccess, - /// `class X : virtual public Base` — virtual (diamond-resolving) base. - #[serde(default)] - pub virtual_base: bool, -} - -/// C++ access specifiers for inheritance + members. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] -pub enum CppAccess { - /// `public` — visible everywhere. - #[default] - Public, - /// `protected` — visible to the class and its derivatives. - Protected, - /// `private` — visible only to the class itself. - Private, -} - -/// One data-member declaration. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct CppField { - /// Member name (e.g. `recognizer_`). - pub name: String, - /// Resolved type, verbatim (e.g. `std::unique_ptr`). - /// Carried for downstream consumers; not emitted in the triple. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub type_name: String, -} - -/// One method declaration carrying its C++ property flags. -/// -/// Every method is classified (`rdf:type Function` + `has_function`); each -/// set flag additionally expands to a method-property predicate. The flags -/// are not mutually exclusive (a method can be both `constexpr` and -/// `noexcept`, an `operator` and an `override`). -#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[expect( - clippy::struct_excessive_bools, - reason = "independent C++ method qualifiers (pure-virtual / noexcept / const / static) — \ - not a state machine; any combination is valid, so two-variant enums would be artificial" -)] -pub struct CppMethod { - /// Method name (e.g. `Recognize`). For operators, the spelled name - /// (e.g. `operator==`); the operator kind is also set in - /// [`Self::operator_kind`] so the classification IRI stays stable. - pub name: String, - /// `virtual ... = 0` pure-virtual declaration → `is_pure_virtual`. - #[serde(default)] - pub is_pure_virtual: bool, - /// `constexpr` / `consteval` marker → `is_constexpr` (the kind rides - /// the object slot). `None` for ordinary runtime methods. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub constexpr_kind: Option, - /// `noexcept` exception specification → `is_noexcept`. - #[serde(default)] - pub is_noexcept: bool, - /// `override` of a virtual base method → `virtually_overrides`. The - /// value is the **fully-qualified** overridden base method - /// (`Namespace::Base.method`), so the emitted `{ns}:` IRI joins the base - /// class's own method node. A bare `Base.method` would dangle for any - /// namespaced base (the base class is modeled as `{ns}:Namespace::Base`) - /// — codex P2, PR #8. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub overrides: Option, - /// Operator overload kind (e.g. `operator==`) → `defines_operator`. - /// `None` for non-operator methods. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub operator_kind: Option, - /// C++20 `requires` clause, verbatim → `requires_concept`. `None` when - /// unconstrained. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub requires_clause: Option, - /// Return type, verbatim (e.g. `bool`, `const char *`) → `returns_type`. - /// `None` (and not emitted) for `void` / constructors / destructors — the - /// AST-DLL shape treats absent `returns_type` as "no value returned". - #[serde(default, skip_serializing_if = "Option::is_none")] - pub return_type: Option, - /// Parameter types in signature order, verbatim → one `has_param_type` each. - /// Order + arity are preserved by the `:` object encoding the - /// expander emits (a triple set is unordered, so the position rides the - /// object). The AST-DLL codegen reconstructs the ordered signature from - /// `return_type` + these. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub param_types: Vec, - /// `T method() const;` — a const-qualified member function → `is_const`. - /// The ORM-downcast shape: a const method is a read accessor (no mutation). - #[serde(default)] - pub is_const: bool, - /// `static T method();` — a static member function → `is_static` - /// (class-level, no implicit `this`). - #[serde(default)] - pub is_static: bool, - /// Member access specifier → `has_visibility`. The OO API-surface + - /// intrusiveness signal (public = adapter surface; private/protected = - /// likely internal). Defaults to `Public`. - #[serde(default)] - pub access: CppAccess, -} - -/// `constexpr` vs `consteval` compile-time markers. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ConstexprKind { - /// `constexpr` — usable in a constant expression. - Constexpr, - /// `consteval` — an immediate function (MUST run at compile time). - Consteval, -} - -/// One template specialisation or instantiation declaration. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct CppTemplate { - /// Explicit specialisation vs materialised instantiation. - pub kind: CppTemplateKind, - /// Template name + arguments, verbatim (e.g. `GenericVector`). - pub name: String, -} - -/// Whether a [`CppTemplate`] is an explicit specialisation or a -/// materialised instantiation visible in the translation unit. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum CppTemplateKind { - /// `template <> class Foo { … }` — explicit (partial or full) - /// specialisation. Expanded as `template_specialises` (`CppExtracted`). - Specialisation, - /// `Foo` materialised in this TU. Expanded as - /// `template_instantiates` (`Inferred` — single-TU view is incomplete). - Instantiation, -} - -/// One `friend class` / `friend fn` declaration. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct CppFriend { - /// The friend class or function name, verbatim. - pub name: String, -} - -/// One identifier originating from a preprocessor macro expansion. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct CppMacroUse { - /// The identifier that was produced by the expansion. - pub identifier: String, - /// The macro it expanded from. - pub macro_name: String, -} - -/// One `static_assert` declaration in class scope. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct CppStaticAssert { - /// The asserted condition, verbatim. - pub condition: String, -} diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/lib.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/lib.rs deleted file mode 100644 index 62b9a14..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/lib.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! `ruff_spo_triplet` — language-agnostic SPO triplet expansion. -//! -//! # What this crate is -//! -//! A tiny, zero-logic-duplication core that turns a neutral -//! [`ModelGraph`] intermediate representation into deterministic -//! NARS-weighted SPO triples ([`Triple`]), serialised as ndjson that loads -//! directly into the `lance_graph` SPO store. -//! -//! It exists so that **business logic extracted from different source -//! languages produces the same ontology graph**. The Python/Odoo frontend -//! (`ruff_python_dto_check`) and a future Ruby/Rails frontend (`OpenProject`) -//! both: -//! -//! 1. parse their own AST, -//! 2. fill a [`ModelGraph`] (the only language-specific work), and -//! 3. call [`expand`] + [`ndjson::to_ndjson`]. -//! -//! The triple vocabulary, the provenance/truth calibration, and the IRI -//! shape live here once. A new language is a new frontend, not a new -//! ontology. -//! -//! ```text -//! Python AST ─┐ -//! ├─► ModelGraph (IR) ─► expand() ─► Vec ─► ndjson ─► SPO store -//! Ruby AST ──┘ ▲ ▲ ▲ -//! language-specific THIS CRATE THIS CRATE -//! ``` -//! -//! # The triple schema -//! -//! | predicate | subject | object | provenance | -//! | --- | --- | --- | --- | -//! | `rdf:type` | `ns:model` | `ogit:ObjectType` | Structural | -//! | `rdf:type` | `ns:model.field` | `ogit:Property` | Structural | -//! | `rdf:type` | `ns:model.fn` | `ogit:Function` | Structural | -//! | `has_function` | `ns:model` | `ns:model.fn` | Structural | -//! | `emitted_by` | `ns:model.field` | `ns:model.fn` | Authoritative | -//! | `depends_on` | `ns:model.field` | `ns:model.` | Authoritative | -//! | `reads_field` | `ns:model.fn` | `ns:model.field` | Inferred | -//! | `raises` | `ns:model.fn` | `exc:` | Authoritative | -//! | `traverses_relation` | `ns:model.fn` | `ns:model.` | Inferred | -//! -//! See `SPO_TRIPLET_EXTRACTION.md` (this crate's root) for the full -//! methodology, the "a + b → c through d" query it enables, and the -//! step-by-step guide to writing a new language frontend (incl. the -//! `OpenProject` Ruby/Rails mapping). -//! -//! # Why these and not RDF libraries -//! -//! The vocabulary is closed and tiny. A full RDF/OWL stack would add -//! hundreds of dependencies to express seven predicates. This crate is -//! `serde` + `serde_json` only — the same zero-dep ethos as -//! `lance_graph_contract`. - -mod expand; -mod ir; -mod ndjson; -mod reassemble; -mod triple; - -pub use expand::expand; -pub use ir::{ - ActsAs, AssocDecl, AssocKind, AttrDecl, AttrKind, Callback, ConcernKind, ConcernRef, - ConstexprKind, CppAccess, CppBase, CppField, CppFriend, CppMacroUse, CppMethod, - CppStaticAssert, CppTemplate, CppTemplateKind, Delegation, DslCall, DynMethod, Field, Function, - GemDsl, GemKind, Model, ModelGraph, ScopeDecl, ScopeKind, StiInfo, UsingRef, Validation, - ValidationKind, -}; -pub use ndjson::{ParseError, from_ndjson, to_ndjson}; -pub use reassemble::{cpp_projection, reassemble}; -pub use triple::{EntityKind, Predicate, Provenance, Triple}; - -#[cfg(test)] -mod integration_tests { - use super::*; - - /// End-to-end: build a two-model graph, expand, serialise, parse back. - #[test] - fn two_model_graph_round_trips_through_ndjson() { - let mut graph = ModelGraph::new("openproject"); - graph.models.push(Model { - name: "WorkPackage".to_string(), - fields: vec![Field { - name: "total_hours".to_string(), - depends_on: vec!["time_entries.hours".to_string()], - emitted_by: Some("compute_total_hours".to_string()), - ..Default::default() - }], - functions: vec![Function { - name: "compute_total_hours".to_string(), - reads: vec!["status".to_string()], - raises: vec!["ActiveRecord::RecordInvalid".to_string()], - traverses: vec!["time_entries".to_string()], - ..Default::default() - }], - ..Default::default() - }); - graph.models.push(Model::new("Project")); - - let triples = expand(&graph); - let text = to_ndjson(&triples); - let parsed = from_ndjson(&text).expect("round-trips"); - assert_eq!(parsed, triples); - - // The Ruby exception namespacing survives. - assert!( - triples - .iter() - .any(|t| t.p == "raises" && t.o == "exc:ActiveRecord::RecordInvalid") - ); - // Both models classified. - assert_eq!( - triples - .iter() - .filter(|t| t.p == "rdf:type" && t.o == "ogit:ObjectType") - .count(), - 2 - ); - } -} diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/ndjson.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/ndjson.rs deleted file mode 100644 index b65b0ef..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/ndjson.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Newline-delimited JSON I/O for triples. -//! -//! The on-disk format is exactly what -//! `lance_graph::graph::spo::odoo_ontology::parse_triples` reads: one -//! `{"s","p","o","f","c"}` object per line. Writing through this module -//! guarantees the downstream store loads it without a transform. - -use crate::triple::{Predicate, Triple}; - -/// Serialise triples to ndjson (one object per line, trailing newline). -/// -/// Order is preserved as given — call [`crate::expand`] first if you want -/// the canonical sorted form. -#[must_use] -pub fn to_ndjson(triples: &[Triple]) -> String { - let mut out = String::new(); - for t in triples { - // serde_json on a flat 5-field struct cannot fail; fall back to a - // skip rather than panicking if it somehow does. - if let Ok(line) = serde_json::to_string(t) { - out.push_str(&line); - out.push('\n'); - } - } - out -} - -/// Parse ndjson into triples. Blank lines are skipped. -/// -/// Returns `Err` with the 1-based line number of the first malformed line. -/// Unlike the downstream loader (which silently drops bad lines for -/// resilience), the extractor side fails loud so a corrupt emit is caught -/// at the source. -/// -/// # Validation -/// -/// Two failure modes per line: -/// -/// 1. **JSON shape** — `serde_json::from_str` rejects lines that aren't a -/// well-formed `{s, p, o, f, c}` object. -/// 2. **Closed predicate vocabulary** — every `t.p` must round-trip through -/// [`crate::Predicate::from_str`]. A typo like `"depend_on"` (missing -/// `s`) parses as a JSON `Triple` but is rejected here, because such a -/// triple would silently disappear from downstream `depends_on` queries. -/// This matches the contract stated in [`crate::Predicate`]'s doc: -/// "frontends MUST NOT emit raw predicate strings". -pub fn from_ndjson(ndjson: &str) -> Result, ParseError> { - let mut out = Vec::new(); - for (i, line) in ndjson.lines().enumerate() { - if line.trim().is_empty() { - continue; - } - let t: Triple = match serde_json::from_str(line) { - Ok(t) => t, - Err(source) => { - return Err(ParseError { - line: i + 1, - message: source.to_string(), - }); - } - }; - if Predicate::from_str(&t.p).is_none() { - return Err(ParseError { - line: i + 1, - message: format!( - "unknown predicate `{}` (not in the closed vocabulary of \ - {} predicates — see `Predicate::ALL` for the full list)", - t.p, - Predicate::ALL.len() - ), - }); - } - out.push(t); - } - Ok(out) -} - -/// A malformed ndjson line. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParseError { - /// 1-based line number of the offending line. - pub line: usize, - /// The underlying `serde_json` error message. - pub message: String, -} - -impl std::fmt::Display for ParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "malformed ndjson at line {}: {}", - self.line, self.message - ) - } -} - -impl std::error::Error for ParseError {} - -#[cfg(test)] -mod tests { - use super::*; - use crate::triple::Provenance; - - fn sample() -> Vec { - vec![ - Triple::new( - "odoo:account_move", - Predicate::RdfType, - "ogit:ObjectType", - Provenance::Structural, - ), - Triple::new( - "odoo:account_move.amount_total", - Predicate::EmittedBy, - "odoo:account_move._compute_amount", - Provenance::Authoritative, - ), - ] - } - - #[test] - fn ndjson_round_trips() { - let triples = sample(); - let text = to_ndjson(&triples); - let parsed = from_ndjson(&text).expect("valid ndjson"); - assert_eq!(parsed, triples); - } - - #[test] - fn each_triple_is_one_line() { - let text = to_ndjson(&sample()); - assert_eq!(text.lines().count(), 2); - assert!(text.ends_with('\n')); - } - - #[test] - fn blank_lines_skipped() { - let text = "\n\n{\"s\":\"a\",\"p\":\"rdf:type\",\"o\":\"ogit:ObjectType\",\"f\":1.0,\"c\":1.0}\n\n"; - let parsed = from_ndjson(text).expect("valid"); - assert_eq!(parsed.len(), 1); - } - - #[test] - fn malformed_line_reports_line_number() { - let text = "{\"s\":\"a\",\"p\":\"rdf:type\",\"o\":\"ogit:ObjectType\",\"f\":1.0,\"c\":1.0}\nNOT JSON\n"; - let err = from_ndjson(text).expect_err("should fail"); - assert_eq!(err.line, 2); - } - - /// Closed-vocabulary enforcement: a typo in the predicate field (e.g. - /// `depend_on` instead of `depends_on`) parses as a well-formed JSON - /// `Triple` but must be rejected — otherwise it disappears silently - /// from downstream `depends_on` queries (codex P2 review on PR #4). - #[test] - fn unknown_predicate_is_rejected() { - let text = - "{\"s\":\"odoo:a.b\",\"p\":\"depend_on\",\"o\":\"odoo:a.c\",\"f\":0.95,\"c\":0.9}\n"; - let err = from_ndjson(text).expect_err("typo predicate must fail loud"); - assert_eq!(err.line, 1); - assert!( - err.message.contains("unknown predicate"), - "error must name the closed-vocabulary violation, got: {}", - err.message - ); - assert!( - err.message.contains("depend_on"), - "error must echo the offending predicate, got: {}", - err.message - ); - } - - /// Every canonical predicate must parse cleanly — guards against - /// `from_str` drifting away from the `to_ndjson` writer. Iterates the - /// full `Predicate::ALL` surface (7 core + 27 `OpenProject` AR-shape). - #[test] - fn every_canonical_predicate_parses() { - for p in Predicate::ALL { - let line = format!( - "{{\"s\":\"x\",\"p\":\"{}\",\"o\":\"y\",\"f\":1.0,\"c\":1.0}}\n", - p.as_str() - ); - from_ndjson(&line) - .unwrap_or_else(|e| panic!("canonical predicate {} rejected: {e}", p.as_str())); - } - } -} diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/reassemble.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/reassemble.rs deleted file mode 100644 index ca2b3da..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/reassemble.rs +++ /dev/null @@ -1,663 +0,0 @@ -//! Reassembly — the inverse of the C++ machine-plane projection of [`expand`]. -//! -//! [`crate::expand`] flattens a [`ModelGraph`] into a sorted, de-duplicated -//! `Vec`. This module walks that triple set back into a -//! [`ModelGraph`], recovering the per-class structure (members, methods, -//! bases, templates, friends, macro uses, static asserts) that the AST-DLL -//! codegen consumes as its stage-1 input. -//! -//! It is the first half of the `ruff_cpp_spo` → Rust-adapter generator: the -//! harvester emits ndjson, [`crate::from_ndjson`] parses it back to -//! [`Triple`]s, and [`reassemble`] groups those triples into the typed -//! per-class surface the emitter walks. -//! -//! # Scope -//! -//! This recovers the **C++ machine-plane projection** only — the seven -//! `Cpp*` sibling collections on [`Model`] plus the class identity. The -//! core-7 (`fields` / `functions`) and the `OpenProject` AR-shape collections -//! are intentionally not reconstructed (the codegen target is the C++ adapter -//! surface). Feed it the output of [`crate::expand`] over a C++-plane graph. -//! -//! # The round-trip property (the falsifier) -//! -//! `reassemble(expand(g))` equals `g` *projected to its emitted form* — i.e. -//! with the three fields [`crate::expand`] deliberately drops blanked to -//! their defaults: [`CppField::type_name`] (→ empty), [`CppBase::access`] (→ -//! [`CppAccess::Public`]) and [`CppBase::virtual_base`] (→ `false`). The test -//! module asserts this on a fixture exercising every C++ arm, plus the two -//! adversarial cases that make the property a real measurement rather than a -//! tautology: two classes sharing a method name (no cross-attribution) and a -//! single class with overloaded methods (no overload collapse). Because the -//! check compares against the live `g` and not a frozen golden file, it is -//! immune to harvester-vocabulary drift — only a genuine reassembly bug -//! turns it red. -//! -//! # Why method identity comes from `has_param_type`, not the IRI suffix -//! -//! The per-overload method IRI carries a `()` suffix for -//! disambiguation, but a templated parameter type contains commas -//! (`std::map`), so the suffix has no clean `,`-split inverse. -//! Reassembly therefore recovers the ordered parameter list from the -//! index-prefixed `has_param_type` triples (`0:int`, `1:const Image &`), -//! reconstructs the exact suffix from them, and strips that suffix off the -//! IRI to recover the method name. No delimiter inside the suffix is ever -//! guessed. - -use std::collections::BTreeMap; - -use crate::ir::{ - ConstexprKind, CppAccess, CppBase, CppField, CppFriend, CppMacroUse, CppMethod, - CppStaticAssert, CppTemplate, CppTemplateKind, Model, ModelGraph, -}; -use crate::triple::Triple; - -/// Mutable accumulator for one method's properties while the triple set is -/// scanned. Finalised into a [`CppMethod`] once every property triple has -/// been routed to it. -#[derive(Default)] -#[expect( - clippy::struct_excessive_bools, - reason = "accumulator mirroring CppMethod's independent C++ qualifiers \ - (pure-virtual / noexcept / const / static) — any combination is valid" -)] -struct MethodAcc { - /// `index → parameter type`, from the `:` `has_param_type` - /// objects. A `BTreeMap` keeps the parameters in signature order. - params: BTreeMap, - return_type: Option, - is_pure_virtual: bool, - constexpr_kind: Option, - is_noexcept: bool, - overrides: Option, - operator_kind: Option, - requires_clause: Option, - is_const: bool, - is_static: bool, - access: CppAccess, -} - -/// Reassemble the C++ machine-plane projection of a triple set into a -/// [`ModelGraph`]. -/// -/// See the module docs for the scope, the round-trip property, and why -/// method identity is recovered from `has_param_type` rather than the IRI -/// suffix. The returned graph is canonicalised (collections sorted, the -/// three never-emitted fields blanked to their defaults) so it compares -/// equal to `expand`'s emitted projection of the source graph. -#[must_use] -pub fn reassemble(triples: &[Triple]) -> ModelGraph { - // The namespace is the prefix of any class anchor's subject IRI. - let namespace = triples - .iter() - .find(|t| t.p == "rdf:type" && t.o == "ogit:ObjectType") - .and_then(|t| t.s.split_once(':')) - .map_or_else(String::new, |(ns, _)| ns.to_string()); - let ns_prefix = format!("{namespace}:"); - - // Pass 1 — class anchors. Identity comes from the explicit - // `(class, rdf:type, ogit:ObjectType)` triple, never from string- - // splitting a member IRI (anchor-first attribution). - let mut classes: BTreeMap = BTreeMap::new(); - for t in triples { - if t.p == "rdf:type" && t.o == "ogit:ObjectType" { - let name = t.s.strip_prefix(&ns_prefix).unwrap_or(&t.s).to_string(); - classes - .entry(t.s.clone()) - .or_insert_with(|| Model::new(name)); - } - } - - // Pass 2 — the `has_function` edges are the explicit class → method - // links; they seed the method accumulators and the owner map so every - // method-property triple routes to the right class without prefix - // matching. - let mut method_owner: BTreeMap = BTreeMap::new(); - let mut method_acc: BTreeMap = BTreeMap::new(); - for t in triples { - if t.p == "has_function" && classes.contains_key(&t.s) { - method_owner.insert(t.o.clone(), t.s.clone()); - method_acc.entry(t.o.clone()).or_default(); - } - } - - // Pass 3 — route every remaining triple by its subject: method-property - // predicates land in the method accumulator; class-level facts land on - // the owning model. - for t in triples { - match t.p.as_str() { - "has_param_type" => { - if let Some(acc) = method_acc.get_mut(&t.s) - && let Some((idx, ty)) = t.o.split_once(':') - && let Ok(i) = idx.parse::() - { - acc.params.insert(i, ty.to_string()); - } - } - "returns_type" => { - if let Some(acc) = method_acc.get_mut(&t.s) { - acc.return_type = Some(t.o.clone()); - } - } - "is_pure_virtual" => { - if let Some(acc) = method_acc.get_mut(&t.s) { - acc.is_pure_virtual = true; - } - } - "is_constexpr" => { - if let Some(acc) = method_acc.get_mut(&t.s) { - acc.constexpr_kind = Some(match t.o.as_str() { - "consteval" => ConstexprKind::Consteval, - _ => ConstexprKind::Constexpr, - }); - } - } - "is_noexcept" => { - if let Some(acc) = method_acc.get_mut(&t.s) { - acc.is_noexcept = true; - } - } - "virtually_overrides" => { - if let Some(acc) = method_acc.get_mut(&t.s) { - let base = t.o.strip_prefix(&ns_prefix).unwrap_or(&t.o).to_string(); - acc.overrides = Some(base); - } - } - "defines_operator" => { - if let Some(acc) = method_acc.get_mut(&t.s) { - acc.operator_kind = Some(t.o.clone()); - } - } - "requires_concept" => { - if let Some(acc) = method_acc.get_mut(&t.s) { - acc.requires_clause = Some(t.o.clone()); - } - } - "is_const" => { - if let Some(acc) = method_acc.get_mut(&t.s) { - acc.is_const = true; - } - } - "is_static" => { - if let Some(acc) = method_acc.get_mut(&t.s) { - acc.is_static = true; - } - } - "has_visibility" => { - if let Some(acc) = method_acc.get_mut(&t.s) { - acc.access = match t.o.as_str() { - "protected" => CppAccess::Protected, - "private" => CppAccess::Private, - _ => CppAccess::Public, - }; - } - } - "has_field" => { - if let Some(model) = classes.get_mut(&t.s) { - let prefix = format!("{}.", t.s); - let name = t.o.strip_prefix(&prefix).unwrap_or(&t.o).to_string(); - model.member_fields.push(CppField { - name, - type_name: String::new(), - }); - } - } - "inherits_from" => { - if let Some(model) = classes.get_mut(&t.s) { - let name = t.o.strip_prefix(&ns_prefix).unwrap_or(&t.o).to_string(); - model.bases.push(CppBase { - name, - access: CppAccess::Public, - virtual_base: false, - }); - } - } - "template_specialises" => { - if let Some(model) = classes.get_mut(&t.s) { - model.templates.push(CppTemplate { - kind: CppTemplateKind::Specialisation, - name: t.o.clone(), - }); - } - } - "template_instantiates" => { - if let Some(model) = classes.get_mut(&t.s) { - model.templates.push(CppTemplate { - kind: CppTemplateKind::Instantiation, - name: t.o.clone(), - }); - } - } - "is_friend_of" => { - if let Some(model) = classes.get_mut(&t.s) { - model.friends.push(CppFriend { name: t.o.clone() }); - } - } - "uses_macro_expansion" => { - if let Some(model) = classes.get_mut(&t.s) - && let Some((ident, macro_name)) = t.o.split_once("<=") - { - model.macro_uses.push(CppMacroUse { - identifier: ident.to_string(), - macro_name: macro_name.to_string(), - }); - } - } - "static_asserts" => { - if let Some(model) = classes.get_mut(&t.s) { - model.static_asserts.push(CppStaticAssert { - condition: t.o.clone(), - }); - } - } - _ => {} - } - } - - // Finalise methods. The ordered parameter list comes from the - // accumulator; the suffix is reconstructed from it and stripped off the - // IRI to recover the name — never split. - for (method_iri, acc) in method_acc { - let Some(class_iri) = method_owner.get(&method_iri) else { - continue; - }; - let Some(model) = classes.get_mut(class_iri) else { - continue; - }; - let param_types: Vec = acc.params.into_values().collect(); - // Reconstruct the exact suffix `expand` built — including the ` const` - // cv-qualifier when the method is const — so the prefix/suffix strip - // recovers the bare name. `is_const` was collected from the property - // triple in pass 3, so it is available here at finalize. - let suffix = format!( - "({}){}", - param_types.join(","), - if acc.is_const { " const" } else { "" } - ); - let class_prefix = format!("{class_iri}."); - let name = method_iri - .strip_prefix(&class_prefix) - .and_then(|rest| rest.strip_suffix(&suffix)) - .unwrap_or(&method_iri) - .to_string(); - model.methods.push(CppMethod { - name, - is_pure_virtual: acc.is_pure_virtual, - constexpr_kind: acc.constexpr_kind, - is_noexcept: acc.is_noexcept, - overrides: acc.overrides, - operator_kind: acc.operator_kind, - requires_clause: acc.requires_clause, - return_type: acc.return_type, - param_types, - is_const: acc.is_const, - is_static: acc.is_static, - access: acc.access, - }); - } - - let mut graph = ModelGraph { - namespace, - models: classes.into_values().collect(), - }; - canonicalize_cpp(&mut graph); - graph -} - -/// The *emitted projection* of a graph: the form `reassemble(expand(g))` -/// recovers. Clones `graph`, blanks the three never-emitted C++ fields -/// ([`CppField::type_name`], [`CppBase::access`], [`CppBase::virtual_base`]), -/// and canonically sorts every C++ collection. -/// -/// For a C++-plane graph whose method IRIs are all distinct, -/// `reassemble(expand(&g)) == cpp_projection(&g)`. The method IRI is cv-aware -/// (it carries the ` const` qualifier), so a const/non-const overload pair — -/// which shares name AND parameter types — stays on distinct nodes rather than -/// collapsing under the `(s, p, o)` dedup. This is the round-trip -/// identity the AST-DLL generator relies on; it is exposed so a consumer can -/// assert it against its own harvested graph. -#[must_use] -pub fn cpp_projection(graph: &ModelGraph) -> ModelGraph { - let mut projected = graph.clone(); - canonicalize_cpp(&mut projected); - projected -} - -/// Sort and de-duplicate every C++ collection, and blank the three fields -/// [`crate::expand`] never emits, so a reassembled graph and a source graph's -/// emitted projection compare equal regardless of source declaration order. -/// -/// De-duplication mirrors `expand`'s `(s, p, o)` dedup: a source graph can -/// carry the same fact twice (e.g. a template-id instantiated in several method -/// signatures yields several identical `template_instantiates`, or a member -/// harvested twice), but `expand` collapses identical triples, so `reassemble` -/// recovers each fact once. The projection must therefore collapse exact -/// duplicates too — otherwise a benign duplicate would read as a round-trip -/// difference. Real collisions (two entries sharing a sort key but differing in -/// content — the genuine overload-collision residual) are NOT equal, so dedup -/// keeps them and they still surface. -fn canonicalize_cpp(graph: &mut ModelGraph) { - for model in &mut graph.models { - for field in &mut model.member_fields { - field.type_name = String::new(); - } - for base in &mut model.bases { - base.access = CppAccess::Public; - base.virtual_base = false; - } - model.member_fields.sort_by(|a, b| a.name.cmp(&b.name)); - model.member_fields.dedup(); - model.bases.sort_by(|a, b| a.name.cmp(&b.name)); - model.bases.dedup(); - // Sort key includes `is_const` — the cv-qualifier is part of the method - // IRI's identity, so a const/non-const overload pair (same name + params) - // must sort deterministically (non-const before const) on both the - // reassembled and the projected side; without it the stable sort - // preserves two different pre-sort orders and the pair compares unequal. - model.methods.sort_by(|a, b| { - (a.name.as_str(), &a.param_types, a.is_const).cmp(&( - b.name.as_str(), - &b.param_types, - b.is_const, - )) - }); - model.methods.dedup(); - model.templates.sort_by(|a, b| { - (a.name.as_str(), tkind_ord(a.kind)).cmp(&(b.name.as_str(), tkind_ord(b.kind))) - }); - model.templates.dedup(); - model.friends.sort_by(|a, b| a.name.cmp(&b.name)); - model.friends.dedup(); - model.macro_uses.sort_by(|a, b| { - (a.identifier.as_str(), a.macro_name.as_str()) - .cmp(&(b.identifier.as_str(), b.macro_name.as_str())) - }); - model.macro_uses.dedup(); - model - .static_asserts - .sort_by(|a, b| a.condition.cmp(&b.condition)); - model.static_asserts.dedup(); - } - graph.models.sort_by(|a, b| a.name.cmp(&b.name)); -} - -/// Stable ordering key for a template's kind (specialisations before -/// instantiations of the same name). -fn tkind_ord(kind: CppTemplateKind) -> u8 { - match kind { - CppTemplateKind::Specialisation => 0, - CppTemplateKind::Instantiation => 1, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::expand::expand; - use crate::ir::{CppTemplate, CppTemplateKind}; - - /// Project a source graph to the form `expand` actually emits — the - /// public [`cpp_projection`], i.e. the definition of the round-trip - /// target. - fn projected(graph: &ModelGraph) -> ModelGraph { - cpp_projection(graph) - } - - /// A `Tesseract::Recognizer`-shaped graph exercising every C++ arm — - /// mirrors the `expand` test fixture so the round-trip covers the whole - /// C++ surface (bases, fields, every method-property flag, overload-free - /// methods, templates, friends, macro uses, static asserts). - fn cpp_fixture() -> ModelGraph { - let mut rec = Model::new("Tesseract::Recognizer"); - rec.bases.push(CppBase { - name: "Tesseract::Classify".to_string(), - access: CppAccess::Public, - virtual_base: false, - }); - rec.member_fields.push(CppField { - name: "recognizer_".to_string(), - type_name: "std::unique_ptr".to_string(), - }); - rec.methods.push(CppMethod { - name: "Recognize".to_string(), - is_pure_virtual: false, - constexpr_kind: None, - is_noexcept: true, - overrides: Some("Tesseract::Classify.Recognize(int,const Image &) const".to_string()), - operator_kind: None, - requires_clause: None, - return_type: Some("int".to_string()), - param_types: vec!["int".to_string(), "const Image &".to_string()], - is_const: true, - is_static: false, - access: CppAccess::Public, - }); - rec.methods.push(CppMethod { - name: "Clear".to_string(), - is_pure_virtual: true, - constexpr_kind: None, - is_noexcept: false, - overrides: None, - operator_kind: None, - requires_clause: None, - return_type: None, - param_types: Vec::new(), - is_const: false, - is_static: false, - access: CppAccess::Public, - }); - rec.methods.push(CppMethod { - name: "kMaxRating".to_string(), - is_pure_virtual: false, - constexpr_kind: Some(ConstexprKind::Constexpr), - is_noexcept: false, - overrides: None, - operator_kind: None, - requires_clause: None, - return_type: None, - param_types: Vec::new(), - is_const: false, - is_static: true, - access: CppAccess::Public, - }); - rec.methods.push(CppMethod { - name: "operator==".to_string(), - is_pure_virtual: false, - constexpr_kind: None, - is_noexcept: false, - overrides: None, - operator_kind: Some("operator==".to_string()), - requires_clause: Some("std::equality_comparable".to_string()), - return_type: None, - param_types: Vec::new(), - is_const: false, - is_static: false, - access: CppAccess::Public, - }); - rec.templates.push(CppTemplate { - kind: CppTemplateKind::Specialisation, - name: "GenericVector".to_string(), - }); - rec.templates.push(CppTemplate { - kind: CppTemplateKind::Instantiation, - name: "GenericVector".to_string(), - }); - rec.friends.push(CppFriend { - name: "TessdataManager".to_string(), - }); - rec.macro_uses.push(CppMacroUse { - identifier: "BOOL_MEMBER".to_string(), - macro_name: "INT_MEMBER".to_string(), - }); - rec.static_asserts.push(CppStaticAssert { - condition: "sizeof(int) == 4".to_string(), - }); - ModelGraph { - namespace: "cpp".to_string(), - models: vec![rec], - } - } - - /// The core falsifier: a full round-trip recovers the emitted projection - /// of the source graph exactly. Would fail on any mis-attribution, - /// overload collapse, parameter loss, or name mis-parse. - #[test] - fn round_trip_recovers_cpp_emitted_projection() { - let g = cpp_fixture(); - let got = reassemble(&expand(&g)); - assert_eq!(got, projected(&g)); - } - - /// Two classes that declare an identically-named, identically-signatured - /// method (the real `UNICHARMAP::unichar_to_id` / - /// `UNICHARSET::unichar_to_id` collision) must each keep their own - /// method — no cross-attribution. This is the adversarial case that - /// proves anchor-first attribution works. - #[test] - fn two_classes_same_method_name_stay_distinct() { - let mut g = ModelGraph::new("cpp"); - for class in ["UNICHARMAP", "UNICHARSET"] { - let mut m = Model::new(class); - m.methods.push(CppMethod { - name: "unichar_to_id".to_string(), - return_type: Some("UNICHAR_ID".to_string()), - param_types: vec!["const char *".to_string()], - is_const: true, - ..Default::default() - }); - g.models.push(m); - } - let got = reassemble(&expand(&g)); - assert_eq!(got, projected(&g)); - - let map = got.models.iter().find(|m| m.name == "UNICHARMAP").unwrap(); - let set = got.models.iter().find(|m| m.name == "UNICHARSET").unwrap(); - assert_eq!(map.methods.len(), 1, "UNICHARMAP keeps its own method"); - assert_eq!(set.methods.len(), 1, "UNICHARSET keeps its own method"); - assert_eq!(map.methods[0].name, "unichar_to_id"); - assert_eq!(set.methods[0].name, "unichar_to_id"); - } - - /// Overloads on one class (the two `UNICHARSET::unichar_to_id` arities) - /// must reassemble into two distinct methods, never one merged node. - #[test] - fn overloads_split_into_distinct_methods() { - let mut g = ModelGraph::new("cpp"); - let mut m = Model::new("UNICHARSET"); - m.methods.push(CppMethod { - name: "unichar_to_id".to_string(), - return_type: Some("UNICHAR_ID".to_string()), - param_types: vec!["const char *".to_string()], - is_const: true, - ..Default::default() - }); - m.methods.push(CppMethod { - name: "unichar_to_id".to_string(), - return_type: Some("UNICHAR_ID".to_string()), - param_types: vec!["const char *".to_string(), "int".to_string()], - is_const: true, - ..Default::default() - }); - g.models.push(m); - - let got = reassemble(&expand(&g)); - assert_eq!(got, projected(&g)); - - let set = &got.models[0]; - assert_eq!(set.methods.len(), 2, "two overloads stay distinct"); - // Sorted by (name, param_types): the 1-arg overload sorts first. - assert_eq!(set.methods[0].param_types, vec!["const char *".to_string()]); - assert_eq!( - set.methods[1].param_types, - vec!["const char *".to_string(), "int".to_string()] - ); - } - - /// The GAP-CONST-OVERLOAD fix (D): a const/non-const overload pair sharing - /// name AND parameter types (`T& at(i)` vs `const T& at(i) const`) must - /// reassemble into TWO distinct methods. Before the cv-aware method IRI they - /// collapsed under the `(s, p, o)` dedup (19/67 ccutil classes); the - /// ` const` qualifier in the IRI keeps them apart, and `reassemble` - /// reconstructs it from the recovered `is_const`. - #[test] - fn const_and_nonconst_overload_stay_distinct() { - let mut g = ModelGraph::new("cpp"); - let mut m = Model::new("GenericVector"); - m.methods.push(CppMethod { - name: "at".to_string(), - return_type: Some("T &".to_string()), - param_types: vec!["int".to_string()], - is_const: false, - ..Default::default() - }); - m.methods.push(CppMethod { - name: "at".to_string(), - return_type: Some("const T &".to_string()), - param_types: vec!["int".to_string()], - is_const: true, - ..Default::default() - }); - g.models.push(m); - - let got = reassemble(&expand(&g)); - assert_eq!(got, projected(&g)); - - let v = &got.models[0]; - assert_eq!( - v.methods.len(), - 2, - "const and non-const `at(int)` must stay distinct, not collapse" - ); - assert!(v.methods.iter().all(|method| method.name == "at")); - assert!( - v.methods.iter().any(|method| method.is_const), - "the const overload survives" - ); - assert!( - v.methods.iter().any(|method| !method.is_const), - "the non-const overload survives" - ); - } - - /// Parameter types containing internal commas (`std::map`) - /// must round-trip exactly. This is the baton-auditor's P1(a) guard: the - /// name and params are recovered from the index-prefixed - /// `has_param_type` triples and the reconstructed suffix, never by - /// splitting the IRI's `(params)` text on `,`. - #[test] - fn params_with_internal_commas_recover_exactly() { - let mut g = ModelGraph::new("cpp"); - let mut m = Model::new("Cache"); - m.methods.push(CppMethod { - name: "merge".to_string(), - return_type: Some("void".to_string()), - param_types: vec!["std::map".to_string(), "int".to_string()], - ..Default::default() - }); - g.models.push(m); - - let got = reassemble(&expand(&g)); - assert_eq!(got, projected(&g)); - - let method = &got.models[0].methods[0]; - assert_eq!(method.name, "merge", "name survives comma-bearing params"); - assert_eq!( - method.param_types, - vec!["std::map".to_string(), "int".to_string()], - "ordered params recover from has_param_type, not a comma split" - ); - } - - #[test] - fn empty_triples_yield_empty_graph() { - let got = reassemble(&[]); - assert!(got.models.is_empty()); - assert!(got.namespace.is_empty()); - } - - #[test] - fn reassembly_is_deterministic() { - let triples = expand(&cpp_fixture()); - assert_eq!(reassemble(&triples), reassemble(&triples)); - } -} diff --git a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/triple.rs b/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/triple.rs deleted file mode 100644 index ca955ac..0000000 --- a/vendor/AdaWorldAPI-ruff/crates/ruff_spo_triplet/src/triple.rs +++ /dev/null @@ -1,1094 +0,0 @@ -//! The triple itself + its closed predicate / entity-kind / provenance -//! vocabularies. -//! -//! These four types are the entire ontological surface. Everything a -//! language frontend produces collapses into a `Vec` whose `p` -//! field is one of [`Predicate`], whose `rdf:type` objects are one of -//! [`EntityKind`], and whose `(f, c)` truth comes from a [`Provenance`] -//! tier. Keeping these closed is what lets the Python (Odoo) and Ruby -//! (Rails) frontends emit byte-identical graphs. - -use serde::{Deserialize, Serialize}; - -/// One SPO triple with NARS truth `(frequency, confidence)`. -/// -/// `(s, p, o)` is the identity. `(f, c)` carries provenance strength: -/// structural facts are certain, decorator/body-authoritative facts are -/// strong, body-inferred facts are weaker. The downstream store -/// (`lance_graph::graph::spo`) gates queries by NARS expectation, so the -/// truth tier is load-bearing, not decorative. -/// -/// This mirrors `lance_graph::graph::spo::odoo_ontology::OntologyTriple` -/// field-for-field so the ndjson this crate writes loads into that store -/// with no transform. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Triple { - /// Subject IRI, e.g. `odoo:account_move.amount_total`. - pub s: String, - /// Predicate IRI, e.g. `depends_on`. - pub p: String, - /// Object IRI, e.g. `odoo:account_move.line_ids.balance`. - pub o: String, - /// NARS frequency. - pub f: f32, - /// NARS confidence. - pub c: f32, -} - -impl Triple { - /// Construct a triple from typed parts + a provenance tier. - #[must_use] - #[expect( - clippy::many_single_char_names, - reason = "s/p/o/f/c are Triple's canonical SPO + NARS-truth field names" - )] - pub fn new(s: impl Into, p: Predicate, o: impl Into, prov: Provenance) -> Self { - let (f, c) = prov.truth(); - Self { - s: s.into(), - p: p.as_str().to_string(), - o: o.into(), - f, - c, - } - } - - /// The identity key — what de-duplication and round-trip equality - /// compare. Truth values are deliberately excluded. - #[must_use] - pub fn key(&self) -> (&str, &str, &str) { - (&self.s, &self.p, &self.o) - } -} - -/// The closed predicate vocabulary. -/// -/// Adding a predicate is a deliberate ontology change: a new variant here, -/// a new arm in [`Predicate::as_str`] / [`Predicate::from_str`], and a -/// decision about which [`Provenance`] tier it carries. Frontends MUST NOT -/// emit raw predicate strings — they go through this enum so the Python and -/// Ruby graphs cannot drift. -/// -/// # Origin tiers (count = 34) -/// -/// 1. **Core 7** (`RdfType` … `TraversesRelation`) — Odoo Python harvest, -/// the original Foundry-shape ontology. Object/Property/Function with -/// declared / body-authoritative / body-inferred truth tiers. -/// 2. **`OpenProject` AR-shape 27** (`DeclaresAssociation` … `UsesRefinement`) -/// — Rails `ActiveRecord` class-body DSL surface, measured on the -/// `OpenProject` corpus (941 models / 1696 declarations / 78 distinct -/// names → 67 emit categories + 11 scope markers; the 27 here cover -/// every emit category that is not already in the core 7). Default -/// tier: [`Provenance::OpenProjectExtracted`] — one notch below -/// [`Provenance::Authoritative`] (Odoo `@api.depends`) to encode the -/// Ruby metaprogramming surface delta. Two per-edge overrides: -/// [`Self::DefinesMethod`] defaults to [`Provenance::Inferred`] -/// (dynamic-method finds are heuristic by definition); -/// [`Self::ConcernIncludedBlock`] / [`Self::ConcernClassMethods`] are -/// structural-by-construction and carry [`Provenance::Structural`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Predicate { - // ───── Core (Odoo Python harvest) ───── - /// `(subject, rdf:type, EntityKind)` — structural classification. - RdfType, - /// `(model, has_function, model.fn)` — a model owns a function. - HasFunction, - /// `(model.field, emitted_by, model.fn)` — the function writes the field. - EmittedBy, - /// `(model.field, depends_on, model.dep)` — declared compute dependency. - DependsOn, - /// `(model.fn, reads_field, model.field)` — body reads the field. - ReadsField, - /// `(model.fn, raises, exc:Type)` — body raises the exception. - Raises, - /// `(model.fn, traverses_relation, model.rel)` — body walks the relation. - TraversesRelation, - - // ───── OpenProject AR-shape (Rails class-body DSL) ───── - // - // Subject is the *class* (not a function) for declarative-level facts; - // the function-level analogue stays on the core 7 (see TraversesRelation - // for the body-walk counterpart of DeclaresAssociation). - /// `(model, declares_association, model.)` — a class-level - /// `belongs_to` / `has_many` / `has_one` / `has_and_belongs_to_many` / - /// `accepts_nested_attributes_for` declaration. **Distinct from** - /// [`Self::TraversesRelation`] (subject = fn, body-walked, Inferred): - /// this is class-level + OpenProject-extracted-grade because the - /// declaration is machine-readable in the AST. - DeclaresAssociation, - /// `(model, validates_constraint, attr|"block")` — `validates` / - /// `validate` / `validates_associated` / `validates_each`. The verb - /// form (vs. the planned `has_constraint`) disambiguates from - /// declarative `has_*` predicates. - ValidatesConstraint, - /// `(model, normalizes_attribute, attr)` — `normalizes :attr, with:` - /// declaration. Distinct from `ValidatesConstraint` because the - /// transformation runs on assignment, not on validation. - NormalizesAttribute, - /// `(model, has_callback, ":")` — `before_save :foo`, - /// `after_create :bar`, etc. The 12 phases are encoded in the object - /// slot, not as separate predicates, so the vocab stays bounded. - HasCallback, - /// `(model, includes_module, mod)` — `include ModX`. Distinct verb - /// per Rails composition rule (include / extend / prepend each have - /// different MRO effects). - IncludesModule, - /// `(model, extends_module, mod)` — `extend ModX`. - ExtendsModule, - /// `(model, prepends_module, mod)` — `prepend ModX` (rare; 4 sites). - PrependsModule, - /// `(concern_mod, concern_class_methods, "block")` — `class_methods do - /// … end` block inside a concern. Marker-only: structural-by- - /// construction; the contained `def`s become regular `has_function` - /// triples in the second pass. - ConcernClassMethods, - /// `(concern_mod, concern_included_block, "block")` — `included do - /// … end` block inside a concern. Same shape as `ConcernClassMethods`. - ConcernIncludedBlock, - /// `(model, has_attribute, attr)` — `attribute :x, :type` / - /// `attr_accessor :x` / `attr_reader :x` / `attr_readonly :x` / - /// `store_attribute …` / `store_accessor …` / `serialize :x` / - /// `enum :x` / `define_attribute_method :x`. The unified declaration - /// surface for non-DB-column attributes. - HasAttribute, - /// `(model, aliases_attribute, "=")` — `alias_attribute`. - AliasesAttribute, - /// `(model, aliases_method, "=")` — `alias_method` / - /// `alias`. Method-level alias (vs. attribute-level above). - AliasesMethod, - /// `(model.column, column_override, "=")` — DSL that - /// overrides a column's behavior (e.g. `serialize :data, JSON`, - /// `undef_method :foo`). Marker for non-default column treatment. - ColumnOverride, - /// `(model, delegates_to, "=>via:")` — `delegate :foo, - /// :bar, to: :baz`. Object encodes one method per triple (multiple - /// methods in one `delegate` call expand to multiple triples). - DelegatesTo, - /// `(model, has_scope, "=")` — `scope :active, - /// -> { where(active: true) }`. Lambda body kept as a body-source ref - /// (per `scope` precedent). - HasScope, - /// `(model, has_default_scope, "")` — `default_scope - /// -> { … }`. One per class at most. - HasDefaultScope, - /// `(model, acts_as, "[:]")` — the `acts_as_*` - /// family (`acts_as_list`, `acts_as_attachable`, …). The variant - /// name + options live in the object slot; the predicate is one. - ActsAs, - /// `(model, registers_journal_formatter, "=")` — - /// `OpenProject`'s `register_journal_formatter` (27 sites). Promoted - /// out of `has_dsl_call` per iron-rule bulk (74 % of the OP custom - /// registration mass). - RegistersJournalFormatter, - /// `(model, registers_journal_formatted_fields, "")` — - /// `OpenProject`'s `register_journal_formatted_fields` (13 sites). - /// Same promotion rationale as `RegistersJournalFormatter`. - RegistersJournalFormattedFields, - /// `(model, has_dsl_call, "()")` — long-tail catch-all - /// for `OpenProject` custom registrations (`register_query`, - /// `activity_provider_for`, `deprecated_alias`, - /// `associated_to_ask_before_destruction`, `has_details_table` — - /// 5 singletons total, ≤ 6 sites each). Keeps the closed vocab from - /// growing for every one-off DSL while preserving queryability via - /// the name slot. - HasDslCall, - /// `(model, mounts_uploader, "=")` — - /// `CarrierWave`'s `mount_uploader :attr, Class`. - MountsUploader, - /// `(model, has_paper_trail, "")` — `paper_trail` gem's - /// `has_paper_trail` declaration. - HasPaperTrail, - /// `(model, has_closure_tree, "")` — `closure_tree` gem's - /// `has_closure_tree` declaration. - HasClosureTree, - /// `(model, counter_cultures, "=")` — `counter_culture` - /// gem's denormalised-counter declaration. - CounterCultures, - /// `(model, auto_strips, "")` — `auto_strip_attributes` gem's - /// declaration. - AutoStrips, - /// `(model, defines_method, "=")` — - /// `define_method` dynamic method synthesis. **Default truth tier - /// is `Inferred`** (dynamic = heuristic): the 24 sites in the - /// `OpenProject` corpus are inherently un-statically-resolvable. - DefinesMethod, - /// `(model, uses_refinement, "")` — `using - /// Refinement` declaration. - UsesRefinement, - /// `(model.field, field_type, "")` — the static type - /// annotation from a Rails `attribute :name, :type` declaration - /// (e.g. `:integer`, `:string`, `:boolean`, `:datetime`, - /// `:decimal`). Authoritative-grade because the type symbol is a - /// machine-readable AST literal. Downstream consumers - /// (e.g. `op-surreal-ast::from_triples`) map the rails-type - /// string to a `SurrealQL` `Kind` variant. - FieldType, - /// `(model., association_kind, "")` — the Rails - /// association macro that declared the relation, where `` - /// is one of `belongs_to`, `has_many`, `has_one`, - /// `has_and_belongs_to_many`, `accepts_nested_attributes_for`. - /// Sibling to [`Self::DeclaresAssociation`] (which carries the - /// existence fact but drops the kind). - /// - /// **Why this matters for schema codegen:** only `belongs_to` - /// puts a FK column on the declaring class — for `has_many`/ - /// `has_one` the FK lives on the OTHER table. A consumer that - /// emits a `record` FK for every `declares_association` - /// triple produces ~1.9× the columns that actually exist in the - /// DB. The kind triple lets `op-surreal-ast::from_triples` gate - /// FK emission on `kind == belongs_to`. - AssociationKind, - - /// `(., class_name, "")` — Rails - /// `class_name:` association option override (`belongs_to :owner, - /// class_name: 'User'`). - /// - /// Subject is the relation IRI (`openproject:WorkPackage.owner`), - /// object is the Ruby class name verbatim (`"User"`). When - /// present, downstream consumers MUST use this as the target - /// class instead of inferring it from the Rails camelcase-singular - /// convention on the relation name. Without this, the schema - /// emits a phantom `record` for a `belongs_to :owner, - /// class_name: 'User'` declaration — the relation name doesn't - /// map to a real table. - /// - /// Only emitted when the `AssocDecl.options` carries a - /// `class_name` key; absence means "use the Rails convention". - ClassName, - - /// `(., validation_kind, "")` — Rails - /// `validates :attr, : true` option keys (`presence`, - /// `uniqueness`, `length`, `format`, `numericality`, `inclusion`, - /// `exclusion`, `acceptance`, `confirmation`). - /// - /// Subject is the validated attribute IRI - /// (`openproject:WorkPackage.subject`), object is the canonical - /// Rails validation key. One validation declaration with multiple - /// kinds emits multiple triples (`validates :email, presence: - /// true, format: { with: /…/ }` → two `validation_kind` triples). - /// - /// Distinct from the existence-of-validation - /// [`Self::ValidatesConstraint`] triple (subject = model). Both - /// are emitted so the consumer can choose: graph traversal joins - /// on `validates_constraint`, schema-quality consumers gate on - /// `validation_kind` to emit richer `ASSERT` clauses or `UNIQUE` - /// indices. - ValidationKind, - - /// `(., validation_param, ":=")` — - /// the inner-hash option values carried by a parametric Rails - /// validator: `validates :foo, length: { maximum: 255 }` yields - /// `(.foo, validation_param, "length:maximum=255")`. - /// - /// One triple per parametric option key (a `length: { maximum: - /// 255, minimum: 3 }` declaration emits two `validation_param` - /// triples). Subject is the validated-attribute IRI (mirrors - /// [`Self::ValidationKind`]); object is the canonical - /// `:=` form. - /// - /// The schema-quality consumer reads these alongside - /// `validation_kind` to lift Rails parametric validators into - /// richer `SurrealQL` clauses than the catch-all - /// `$value != NONE` fallback — e.g. `length:maximum=255` lowers - /// to `string::len($value) <= 255`, - /// `numericality:greater_than=0` to `$value > 0`. - /// - /// Only emitted when the option value is a hash literal - /// (`{key: value, ...}`); non-hash kinds (`presence: true`, - /// `uniqueness: true`, …) emit only the `validation_kind` - /// triple — there's no inner-hash to surface. - ValidationParam, - - // ───── C++ machine-plane (libclang harvest — ruff_cpp_spo) ───── - // - // The 13 net-new predicates for the C++ frontend. Subject conventions - // mirror the existing surface: class-scoped facts take the class IRI as - // subject (`inherits_from`, `has_field`, `is_friend_of`, - // `template_*`, `static_asserts`), method-scoped properties take the - // method IRI (`is_pure_virtual`, `is_constexpr`, `is_noexcept`, - // `virtually_overrides`, `defines_operator`, `requires_concept`). - // Default tier is [`Provenance::CppExtracted`] (declarative C++ surface - // is machine-readable from the AST); three per-edge overrides encode the - // metaprogramming residual: [`Self::IsFriendOf`] is Structural (purely - // declarative), [`Self::UsesMacroExpansion`] and - // [`Self::TemplateInstantiates`] are Inferred (macro provenance + single- - // TU instantiation visibility are heuristic). - /// `(class, inherits_from, base_class)` — class inheritance. - /// One triple per base. - /// - /// **Cross-frontend predicate.** Both C++ class inheritance and - /// Rails STI (Single Table Inheritance, `class Foo < - /// ApplicationRecord` with `inheritance_column`) emit this. Wire - /// shape is identical; per-emission provenance differs: - /// - /// - C++ frontend (`ruff_cpp_spo`): default - /// [`Provenance::CppExtracted`]. The access specifier - /// (public/protected/private) and virtual-inheritance flag are - /// carried on [`crate::CppBase`] but not emitted in the triple. - /// - Rails frontend (`ruff_ruby_spo` / `ruff_spo_triplet::expand`): - /// emitted with [`Provenance::OpenProjectExtracted`] from the - /// `StiInfo` slot. The `abstract_class` / - /// `inheritance_column` metadata are carried on the IR only. - /// - /// Distinct from [`Self::IncludesModule`] which is method-body - /// composition — STI shares a single physical table via a - /// type-discriminator column. - InheritsFrom, - /// `(class, has_field, class.field)` — a data-member declaration. The - /// resolved type is carried on the IR ([`crate::CppField`]); the C++ - /// member field is also classified `(class.field, rdf:type, Property)`. - HasField, - /// `(class, template_specialises, "