DBax127 commited on
Commit
03128a2
·
1 Parent(s): 7dbbe3a

depends: what a string names and what a subscript picks are reads

Browse files

mapped()'s siblings now read the fields they name in a string literal,
through _named_reads (was _mapped_reads): filtered("a.b"), grouped("f"),
sorted("f") and, on Odoo 19, an order such as "date desc, id", and the
leaf fields of a filtered_domain() literal. _attr_path and
_resolve_iterable look through a subscript whose index is a literal
integer or a slice, so rec.line_ids[0].amount reads line_ids.amount;
record[name] and x[i] still stop the path. Every exemption applies to
the new reads. constrains-must-list-every-field-read makes the same
reads, and depends-must-not-name-unread-fields counts them when it
steps aside, which moved nothing.

OCA 99 -> 101, OpenSPP 157 -> 163, core 2338 -> 2358, nothing lost and
no other rule moved. All 28 read: 18 true, 10 false.
OCA, 2 true (account_move_name_sequence's journal hash flag,
account_spread_cost_revenue's line_ids), into classification-oca.
OpenSPP, 6 true (five spp_programs registrant counts filtered on
is_registrant, spp_approval's tier sequence), into
classification-openspp-rest.
Core, 20. True: hr_expense_sheet reversal, the accrual plan and level
units, hr_holidays_contract employee, gelato template ref, stock.scrap
line unit, POS product colour, base_automation, sale_expense_margin,
l10n_id_efaktur_coretax. False, each a class the checker already had:
unstored in another module (base_import_module, l10n_latam), depends
merged from sale_project (sale_timesheet), covered through another
compute (hr_expense, website_slides), a value kept on purpose (payment
term days, l10n_id_efaktur's name), writes refused while a session is
open (POS cash journal, two), a non-stored compute in constrains
(mail activity schedule). filtered_domain() and grouped() add nothing
on the three trees. 288 of 288 hand-classified true positives still
reported. ARCHITECTURE 3.71.

ARCHITECTURE.md CHANGED
@@ -2612,7 +2612,7 @@ all of them, and in more than one direction at once.
2612
 
2613
  `bin/figures.py` recomputes every present-tense figure from the committed artifacts, and
2614
  `--check` fails on any figure in README.md or ARCHITECTURE.md that disagrees. A figure is
2615
- marked where it stands, `<!--fig:oca.rate-->2.9<!--/fig-->`, which renders as nothing.
2616
  Dated sentences are not marked: "re-measured on 2026-09-16: 3.5" is a record of a past
2617
  run and stays true after the next one, and a check that demanded it change would be
2618
  demanding that history be rewritten. It runs in CI as its own job, needing no checkout
@@ -3500,6 +3500,154 @@ mistake the rule was written for (§3.7), and the three-segment spelling of it w
3500
  passed there too. The corpus now decides 8 rules by a regex and 19 by an AST pass. All 280
3501
  hand-classified true positives are still reported.
3502
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3503
  ## 4. Performance traps, all found by measurement
3504
 
3505
  Within four instrumented runs the report showed the 7B at ~10 tok/s against a
@@ -3617,12 +3765,12 @@ mutations are formatting-level, so an equivalent algorithm written differently i
3617
  generated, and the samples are still small and still written alongside the patterns.
3618
 
3619
  `nexa fieldtest` (§3.18) closes part of that gap and opens an honest number in its
3620
- place: **<!--fig:oca.rate-->2.9<!--/fig--> hits per 1000 lines** on
3621
  <!--fig:oca.lines-->33,960<!--/fig--> lines of reviewed OCA code, down from 11.3 when
3622
  this started. It was 2.9 after §3.43, and has moved since as rules were composed in and
3623
  narrowed; §3.53 is why this sentence can no longer fall behind it.
3624
 
3625
- <!--fig:oca.read-->96<!--/fig--> of today's <!--fig:oca.findings-->99<!--/fig-->
3626
  findings have been read by hand, which makes it a false-positive rate rather than a hit
3627
  rate; the other <!--fig:oca.unread-->3<!--/fig--> arrived after the last reading. The
3628
  table below is the reading of 2026-09-15, done against the run in §3.42 and re-run
 
2612
 
2613
  `bin/figures.py` recomputes every present-tense figure from the committed artifacts, and
2614
  `--check` fails on any figure in README.md or ARCHITECTURE.md that disagrees. A figure is
2615
+ marked where it stands, `<!--fig:oca.rate-->3.0<!--/fig-->`, which renders as nothing.
2616
  Dated sentences are not marked: "re-measured on 2026-09-16: 3.5" is a record of a past
2617
  run and stays true after the next one, and a check that demanded it change would be
2618
  demanding that history be rewritten. It runs in CI as its own job, needing no checkout
 
3500
  passed there too. The corpus now decides 8 rules by a regex and 19 by an AST pass. All 280
3501
  hand-classified true positives are still reported.
3502
 
3503
+ ### 3.71 What a string names, and what a subscript picks
3504
+
3505
+ **Measured 2026-09-27.** §3.66 taught the depends check that `x.mapped("a.b")` reads `x.a.b`.
3506
+ The sweep that found §3.70's gap found the siblings of `mapped()` still silent, and one more
3507
+ place where a path stopped:
3508
+
3509
+ - `x.filtered("is_paid")` reads `x.is_paid`, and `x.filtered("a.b")` reads `x.a.b`.
3510
+ - `x.sorted("sequence")` reads `x.sequence`. Odoo 18 takes one field name there, as
3511
+ `itemgetter(key)`. On 19 the argument is an order, `"date desc, id"`, whose terms each name a
3512
+ field (`_sorted_order_to_function`, `odoo/orm/models.py` on 19.0).
3513
+ - `x.filtered_domain([("state", "=", "done")])` reads `x.state` for every leaf written out, and
3514
+ through an `any` leaf to the related records. `x.grouped("f")` keys on `x.f`.
3515
+ - `rec.line_ids[0].amount` read nothing past `line_ids`, because `_attr_path` and
3516
+ `_resolve_iterable` stopped at a subscript. `rec.partner_id.child_ids[:1].email` reported
3517
+ `partner_id.child_ids` and never the email, which is the field whose change goes unnoticed.
3518
+
3519
+ `_named_reads` replaces `_mapped_reads` and reads all five methods, literals only. A lambda, a
3520
+ name and a domain built at run time stay unread, and an order with any word after a field but
3521
+ `asc`, `desc`, `nulls`, `first` or `last` is read not at all rather than in part. A subscript is
3522
+ looked through when its index is a literal integer or a slice, since indexing or slicing a
3523
+ recordset gives records of the same path. The other thing a subscript does is `record[name]`,
3524
+ which reads the field called `name`, so a string or a variable as the index stops the path as it
3525
+ did before. Every exemption the check has applies to every new read: a trailing `.id`, a field
3526
+ the method computes, a non-stored compute, a wizard, `display_name`, a declared longer path, and
3527
+ a read inside a `raise` or a log line.
3528
+
3529
+ `constrains-must-list-every-field-read` had only ever read attributes, so
3530
+ `if any(self.mapped("date_end"))` under `@api.constrains("date_start")` was silent. It was
3531
+ measured with the same reads before it took them. `depends-must-not-name-unread-fields` steps
3532
+ aside when the body reads a field its decorator does not declare, because the depends check owns
3533
+ that disagreement, and it now counts the same reads when it decides. That moved nothing on the
3534
+ three trees.
3535
+
3536
+ | | before | after |
3537
+ |---|---|---|
3538
+ | OCA | 99 | **101** |
3539
+ | OpenSPP | 157 | **163** |
3540
+ | odoo core | 2,338 | **2,358** |
3541
+
3542
+ Nothing disappears, and no other rule moves. By part: the literal forms add 1, 6 and 11 depends
3543
+ findings on OCA, OpenSPP and core, the subscripts add 6 on core, and the constrains check adds 1
3544
+ on OCA and 3 on core. `filtered_domain()` and `grouped()` add nothing on the three trees. They
3545
+ name fields exactly as `filtered()` does and are kept for code these trees do not hold. A loop
3546
+ over `enumerate()`, the other shape the sweep sized, added nothing and was left out.
3547
+
3548
+ Every addition was read.
3549
+
3550
+ **OCA, 2, both true**, now in `classification-oca-2026-09.json`. `account_move_name_sequence`
3551
+ sets `made_sequence_gap` to `False` for moves outside a hashed journal with
3552
+ `self.filtered("journal_id.restrict_mode_hash_table")`, and neither its decorator nor
3553
+ `account`'s, which Odoo merges with it, names the flag: securing a journal leaves its existing
3554
+ moves on `False`. `account_spread_cost_revenue`'s journal check reads
3555
+ `mapped("line_ids.move_id")` and declares only `journal_id`. True as the rule states it, though
3556
+ every move the module creates for a line takes the spread's own journal.
3557
+
3558
+ **OpenSPP, 6, all true**, in `classification-openspp-rest-2026-09.json` with the other findings
3559
+ later changes surfaced. `classification-openspp-2026-09.json` is the reading of the two flagship
3560
+ rules made on 2026-09-16 and is quoted as that reading. Five are `spp_programs`' registrant
3561
+ counts, each set to 0 for the partners `self.filtered("is_registrant")` leaves out, without
3562
+ `is_registrant` declared. Every create path in the tree sets the flag at creation and nothing
3563
+ rewrites it. The sixth is `spp_approval`'s current tier, the first pending tier review by
3564
+ `sequence`. A review's `sequence` is a stored related of its tier's, so reordering a
3565
+ definition's tiers while reviews are pending leaves each review on the tier the old order put
3566
+ first.
3567
+
3568
+ **Core, 20: 10 true, 10 false.** The true ones:
3569
+
3570
+ - `hr_expense`'s report is `reversed` once every entry has a reversal
3571
+ (`filtered("reversal_move_ids")`). A company-paid report's entries are `entry` moves, whose
3572
+ `payment_state` and `amount_residual`, the two fields declared, never move on a reversal, so
3573
+ the report stays paid.
3574
+ - `hr_holidays`' accrual plan takes its unit from `level_ids[0].added_value_type`. Changing the
3575
+ plan's time off type recomputes every level's unit, and the plan's own, which allocations
3576
+ read, stays where it was. Each level's copy of the first level's unit is the same shape,
3577
+ reached only by writing the first level directly: the form writes `level_ids` on the plan,
3578
+ which is declared.
3579
+ - `hr_holidays_contract`'s multi-contract check filters on `employee_id` and declares the dates.
3580
+ Moving a leave to an employee whose contracts under it keep different schedules is not
3581
+ checked.
3582
+ - `website_sale_gelato`'s publishing check filters on `gelato_template_ref` and declares
3583
+ `is_published`. A template set on a published product whose print images have no data is not
3584
+ checked. The core sample already holds its sister check on `product.document` as true.
3585
+ - `stock.scrap` takes its quantity from `move_ids[0].quantity` and declares
3586
+ `move_ids.move_line_ids.quantity`, one of the two things a move's quantity is computed from.
3587
+ The other, the lines' unit, is the gap. Narrow.
3588
+ - `point_of_sale` copies the first POS category's colour onto the product and does not follow
3589
+ a later change to it, on a `readonly=False` default. What §3.66 said of `incoterm_location`
3590
+ applies.
3591
+ - Three with a field nothing in core rewrites, as §3.66 counted `response_code`.
3592
+ `base_automation` gives an automatic name only to the actions `filtered("base_automation_id")`
3593
+ keeps, and every core path creates the action already linked. `sale_expense_margin` costs a
3594
+ reinvoiced line from its `expense_id`, set when the line is created. `l10n_id_efaktur_coretax`
3595
+ names a document after its first and last invoice by `sorted("name")`, and a posted invoice
3596
+ changes name only by resequencing.
3597
+
3598
+ The false ones are classes the check already had for attribute reads. None comes from reading a
3599
+ line wrongly:
3600
+
3601
+ - storedness in another module, two. `icon_image` in `base` and `highest_name` in `account` are
3602
+ declared without `store=True`.
3603
+ - a decorator merged from another module, one. `sale_timesheet`'s project partner calls
3604
+ `super()`, and `sale_project`'s `_compute_partner_id` declares `allow_billable`. Odoo merges
3605
+ `@api.depends` across every override (§3.59), and the file in front of the check shows only
3606
+ its own.
3607
+ - covered through another declared compute, two. `hr_expense` filters on `product_has_cost`,
3608
+ which depends only on `product_id`, and the declared `tax_ids` depends on `product_id` too.
3609
+ `website_slides` filters on `is_category`, and flipping it re-sections the channel through
3610
+ `_compute_category_id`, which changes this slide's `slide_ids`, which is declared. This is the
3611
+ class the OpenSPP classification calls `covered-by-its-own-depends`, one step further removed.
3612
+ - a value kept on purpose, two. An `account.payment.term.line` defaults its days to the
3613
+ previous line's plus 30 only while they are unset, so declaring the sibling's days would
3614
+ overwrite a deliberate 0 and make the field depend on itself. `l10n_id_efaktur`'s document
3615
+ name is `readonly=False`, with a docstring saying it is computed at creation and may be
3616
+ renamed.
3617
+ - refused by the model while it matters, two. The POS session's cash journal reads its payment
3618
+ methods' `is_cash_count` and `journal_id` (`filtered("is_cash_count")[:1].journal_id`), and a
3619
+ payment method refuses every write but `sequence` while a session using it is open.
3620
+ - advice that cannot be taken, one, on the constrains side. `mail.activity.schedule` filters on
3621
+ `error`, a non-stored compute that `@api.constrains` cannot name: Odoo logs it as not
3622
+ writeable. The decorator lists `error`'s writable inputs instead, and a comment says so.
3623
+
3624
+ So 28 findings, 18 true. On core the depends additions read 8 of 17, below the 11 of 15 that
3625
+ rule's core sample read (§3.51), and the constrains additions 2 of 3, beside that rule's 10 of
3626
+ 15 (§3.57). On OCA and OpenSPP they read 8 of 8. Each part was weighed apart, as
3627
+ §3.67 weighed its two, and none is left out. Each adds more true findings than false, and the
3628
+ false ones are not a new kind of mistake: they are what this check already says when the same
3629
+ field is read as an attribute, and keeping a spelling silent would not fix them, only hide them
3630
+ behind the spelling. None of the core readings goes into `classification-odoo-core-2026-09.json`,
3631
+ which is a fixed-seed sample. The hand-classified true positives are now 288, and all 288 are
3632
+ still reported.
3633
+
3634
+ What it still does not follow, named so nobody finds it later:
3635
+
3636
+ - a lambda, a name or a variable as the argument, a domain built at run time, and an Odoo 19
3637
+ `Domain` object;
3638
+ - a subscript by a name, `rec.line_ids[i]`, which could be either kind of subscript;
3639
+ - a name assigned a recordset (`pending = ...sorted("sequence")[:1]`, then `pending.tier_id`):
3640
+ `_aliases` binds loop variables only;
3641
+ - `mapped()` on the result of another `mapped()`, and a loop over a `mapped()` result, as §3.66
3642
+ left them;
3643
+ - an attribute read straight off a call, `rec.sudo().partner_id.name` or
3644
+ `rec.line_ids.filtered(...).amount`. Sized and not read: core +9 on the depends check (six
3645
+ through `sudo()`, three through `with_context()`), OCA +1, OpenSPP 0;
3646
+ - a read another declared path already reaches: a read compute's own dependencies
3647
+ (`product_has_cost` above, and OpenSPP's three older `covered-by-its-own-depends`), or a flag
3648
+ whose change re-sections a declared relation (`is_category` above). Following either would be
3649
+ a narrowing of its own.
3650
+
3651
  ## 4. Performance traps, all found by measurement
3652
 
3653
  Within four instrumented runs the report showed the 7B at ~10 tok/s against a
 
3765
  generated, and the samples are still small and still written alongside the patterns.
3766
 
3767
  `nexa fieldtest` (§3.18) closes part of that gap and opens an honest number in its
3768
+ place: **<!--fig:oca.rate-->3.0<!--/fig--> hits per 1000 lines** on
3769
  <!--fig:oca.lines-->33,960<!--/fig--> lines of reviewed OCA code, down from 11.3 when
3770
  this started. It was 2.9 after §3.43, and has moved since as rules were composed in and
3771
  narrowed; §3.53 is why this sentence can no longer fall behind it.
3772
 
3773
+ <!--fig:oca.read-->98<!--/fig--> of today's <!--fig:oca.findings-->101<!--/fig-->
3774
  findings have been read by hand, which makes it a false-positive rate rather than a hit
3775
  rate; the other <!--fig:oca.unread-->3<!--/fig--> arrived after the last reading. The
3776
  table below is the reading of 2026-09-15, done against the run in §3.42 and re-run
README.md CHANGED
@@ -132,9 +132,9 @@ half of the wrapper.
132
 
133
  Measured against **reviewed, merged** OCA code — <!--fig:oca.lines-->33,960<!--/fig-->
134
  lines that passed human review and shipped — the corpus makes
135
- <!--fig:oca.findings-->99<!--/fig--> findings, <!--fig:oca.rate-->2.9<!--/fig--> per
136
- thousand lines. <!--fig:oca.read-->96<!--/fig--> of them have been read by hand:
137
- **<!--fig:oca.read.true-->83<!--/fig--> true positives,
138
  <!--fig:oca.read.false-->6<!--/fig--> false, <!--fig:oca.read.undecidable-->7<!--/fig-->
139
  undecidable from a single file**, and the other <!--fig:oca.unread-->3<!--/fig--> are
140
  newer than the last reading. Every verdict is in the `classification-*.json` files, so the
@@ -143,8 +143,8 @@ fails if this paragraph stops agreeing with them.
143
 
144
  On <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP the rate is
145
  <!--fig:openspp.rate-->0.7<!--/fig-->, and those findings have been read too: of the
146
- <!--fig:openspp.findings-->157<!--/fig--> it makes today,
147
- **<!--fig:openspp.read.true-->134<!--/fig--> true, <!--fig:openspp.read.false-->17<!--/fig-->
148
  false, <!--fig:openspp.read.undecidable-->3<!--/fig--> undecidable**, with
149
  <!--fig:openspp.unread-->3<!--/fig--> not yet read. One rule produced 97 false positives
150
  along the way and has been rewritten because of them — that story is
@@ -609,15 +609,15 @@ Same principle as `z.infer<typeof Schema>`: one definition, derived artifacts, n
609
  | Recall under meaning-preserving rewrite | 79/79 |
610
  | Satisfied patterns bypassable by a comment | 0 of <!--fig:corpus.satisfied_patterns-->1<!--/fig--> |
611
  | Retrievable by their own probe | 27 of 27 at top 6, 25 of them at top 3 |
612
- | Hit rate on <!--fig:oca.lines-->33,960<!--/fig--> lines of reviewed OCA code | <!--fig:oca.rate-->2.9<!--/fig--> per 1000 lines: <!--fig:oca.findings-->99<!--/fig--> findings |
613
- | Those findings, read by hand | **<!--fig:oca.read.true-->83<!--/fig--> true positives**, <!--fig:oca.read.false-->6<!--/fig--> false, <!--fig:oca.read.undecidable-->7<!--/fig--> undecidable from one file, <!--fig:oca.unread-->3<!--/fig--> not yet read |
614
- | Hit rate on <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP (Odoo 19) | <!--fig:openspp.rate-->0.7<!--/fig--> per 1000 lines: <!--fig:openspp.findings-->157<!--/fig--> findings, +<!--fig:openspp.decided-->127<!--/fig--> another tool had already flagged, +<!--fig:openspp.advisory-->82<!--/fig--> advisory |
615
- | Those findings, read by hand | **<!--fig:openspp.read.true-->134<!--/fig--> true positives**, <!--fig:openspp.read.false-->17<!--/fig--> false, <!--fig:openspp.read.undecidable-->3<!--/fig--> undecidable, <!--fig:openspp.unread-->3<!--/fig--> not yet read |
616
  | …the two `depends`/`constrains` rules alone | **<!--fig:cls.openspp.true-->87<!--/fig--> true**, <!--fig:cls.openspp.false-->15<!--/fig--> false, <!--fig:cls.openspp.undecidable-->3<!--/fig--> undecidable: <!--fig:cls.openspp.precision-->85<!--/fig-->% |
617
  | …the 8 composed-in Python rules alone | **<!--fig:cls.python.true-->9<!--/fig--> true**, <!--fig:cls.python.false-->42<!--/fig--> false: <!--fig:cls.python.precision-->18<!--/fig-->%, after 18 datetime verdicts were reversed: Odoo runs in UTC |
618
  | …`sudo-bypasses-record-rules` alone, before it was narrowed | <!--fig:cls.openspp-rest.sudo-bypasses-record-rules.true-->6<!--/fig--> true, <!--fig:cls.openspp-rest.sudo-bypasses-record-rules.false-->97<!--/fig--> false — 5.8%, and the reason it is now an AST pass |
619
- | Hit rate on <!--fig:core.lines-->559,663<!--/fig--> lines of odoo core 18.0 | <!--fig:core.rate-->4.2<!--/fig--> per 1000 lines: <!--fig:core.findings-->2,338<!--/fig--> findings, <!--fig:core.read-->89<!--/fig--> of them read by hand: <!--fig:core.read.true-->45<!--/fig--> true, <!--fig:core.read.false-->44<!--/fig--> false |
620
- | Hand-classified true positives still reported | **<!--fig:survival.found-->280<!--/fig--> of <!--fig:survival.true-->280<!--/fig-->**, across <!--fig:survival.artifacts-->9<!--/fig--> classifications |
621
  | **Recall against real Odoo bugfixes** | **<!--fig:recall.all.caught-->20<!--/fig--> of <!--fig:recall.all.in_scope-->334<!--/fig-->, <!--fig:recall.all.pct-->6<!--/fig-->%, across three ecosystems. See below.** |
622
  | Verdict stability, 12 questions x 5 runs | 12/12 same verdict every run |
623
  | Escalation rescued a bad draft | 11 of 21, 95% CI 32–72% |
@@ -743,8 +743,8 @@ files on a `float_compare` written somewhere else in them.
743
  The check that makes all of this safe is the same one every time, and it is worth
744
  stating as a number rather than a promise: across all
745
  <!--fig:survival.artifacts-->9<!--/fig--> classification artifacts,
746
- **<!--fig:survival.true-->280<!--/fig--> hand-classified true positives,
747
- <!--fig:survival.found-->280<!--/fig--> still found, none lost.** That is asserted by
748
  `python bin/figures.py --check`, which matches every `true` verdict to a finding in the
749
  committed fieldtest artifact for the same checkout at the same commit, and runs in CI.
750
  It is not done by reading the diff and hoping, and no longer by re-running against a
@@ -1055,7 +1055,7 @@ are now classified across the four rules that make 70% of them:
1055
  | `stored-compute-needs-complete-depends` | **11/15** | 49/57 OCA |
1056
  | `assert-is-not-validation` | **3/15** | 6/10 OCA |
1057
  | `sudo-bypasses-record-rules` | **0/15** | 6 true on OpenSPP |
1058
- | `constrains-must-list-every-field-read` | **<!--fig:cls.odoo-core.constrains-must-list-every-field-read.true-->10<!--/fig-->/15** | <!--fig:cls.openspp.constrains-must-list-every-field-read.true-->38<!--/fig-->/<!--fig:cls.openspp.constrains-must-list-every-field-read.rows-->45<!--/fig--> OpenSPP, <!--fig:cls.oca.constrains-must-list-every-field-read.true-->7<!--/fig-->/<!--fig:cls.oca.constrains-must-list-every-field-read.rows-->7<!--/fig--> OCA |
1059
  | `depends-must-not-name-unread-fields` | **<!--fig:cls.odoo-core.depends-must-not-name-unread-fields.true-->5<!--/fig-->/15** | <!--fig:cls.openspp-rest.depends-must-not-name-unread-fields.true-->10<!--/fig-->/<!--fig:cls.openspp-rest.depends-must-not-name-unread-fields.rows-->11<!--/fig--> OpenSPP, <!--fig:cls.oca.depends-must-not-name-unread-fields.true-->3<!--/fig-->/<!--fig:cls.oca.depends-must-not-name-unread-fields.rows-->3<!--/fig--> OCA |
1060
  | `naive-and-aware-datetimes-do-not-compare` | **<!--fig:cls.odoo-core.naive-and-aware-datetimes-do-not-compare.true-->0<!--/fig-->/15** | 3/33 after the re-read below |
1061
 
@@ -1087,7 +1087,7 @@ Reading them turned up three more checker defects, all of the same family as the
1087
  `sudo(False)` one above: a model with `_auto = False` is a SQL view with no foreign key,
1088
  so `ondelete` is not a decision about it; `sudo(flag=False)` is de-escalation by
1089
  keyword; and `x.sudo(self.env.su)` restores the ambient level and cannot raise
1090
- privilege. Core is now **<!--fig:core.findings-->2,338<!--/fig--> findings at
1091
  <!--fig:core.rate-->4.2<!--/fig--> per 1000 lines**.
1092
 
1093
  One finding is worth acting on outside this project. Core's
@@ -1101,7 +1101,7 @@ Seven rules are now sampled on core, <!--fig:cls.odoo-core.rows-->105<!--/fig-->
1101
  read, and the <!--fig:cls.sudo-core.rows-->45<!--/fig--> `sudo` findings a stricter
1102
  re-scoping test added were read in full: <!--fig:cls.sudo-core.true-->1<!--/fig--> true,
1103
  <!--fig:cls.sudo-core.false-->44<!--/fig--> false. Core makes
1104
- <!--fig:core.findings-->2,338<!--/fig--> live ones. The rest stay unread,
1105
  and a rate on core is a statement about how often the rules fire there, not how often
1106
  they are right.
1107
 
 
132
 
133
  Measured against **reviewed, merged** OCA code — <!--fig:oca.lines-->33,960<!--/fig-->
134
  lines that passed human review and shipped — the corpus makes
135
+ <!--fig:oca.findings-->101<!--/fig--> findings, <!--fig:oca.rate-->3.0<!--/fig--> per
136
+ thousand lines. <!--fig:oca.read-->98<!--/fig--> of them have been read by hand:
137
+ **<!--fig:oca.read.true-->85<!--/fig--> true positives,
138
  <!--fig:oca.read.false-->6<!--/fig--> false, <!--fig:oca.read.undecidable-->7<!--/fig-->
139
  undecidable from a single file**, and the other <!--fig:oca.unread-->3<!--/fig--> are
140
  newer than the last reading. Every verdict is in the `classification-*.json` files, so the
 
143
 
144
  On <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP the rate is
145
  <!--fig:openspp.rate-->0.7<!--/fig-->, and those findings have been read too: of the
146
+ <!--fig:openspp.findings-->163<!--/fig--> it makes today,
147
+ **<!--fig:openspp.read.true-->140<!--/fig--> true, <!--fig:openspp.read.false-->17<!--/fig-->
148
  false, <!--fig:openspp.read.undecidable-->3<!--/fig--> undecidable**, with
149
  <!--fig:openspp.unread-->3<!--/fig--> not yet read. One rule produced 97 false positives
150
  along the way and has been rewritten because of them — that story is
 
609
  | Recall under meaning-preserving rewrite | 79/79 |
610
  | Satisfied patterns bypassable by a comment | 0 of <!--fig:corpus.satisfied_patterns-->1<!--/fig--> |
611
  | Retrievable by their own probe | 27 of 27 at top 6, 25 of them at top 3 |
612
+ | Hit rate on <!--fig:oca.lines-->33,960<!--/fig--> lines of reviewed OCA code | <!--fig:oca.rate-->3.0<!--/fig--> per 1000 lines: <!--fig:oca.findings-->101<!--/fig--> findings |
613
+ | Those findings, read by hand | **<!--fig:oca.read.true-->85<!--/fig--> true positives**, <!--fig:oca.read.false-->6<!--/fig--> false, <!--fig:oca.read.undecidable-->7<!--/fig--> undecidable from one file, <!--fig:oca.unread-->3<!--/fig--> not yet read |
614
+ | Hit rate on <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP (Odoo 19) | <!--fig:openspp.rate-->0.7<!--/fig--> per 1000 lines: <!--fig:openspp.findings-->163<!--/fig--> findings, +<!--fig:openspp.decided-->127<!--/fig--> another tool had already flagged, +<!--fig:openspp.advisory-->82<!--/fig--> advisory |
615
+ | Those findings, read by hand | **<!--fig:openspp.read.true-->140<!--/fig--> true positives**, <!--fig:openspp.read.false-->17<!--/fig--> false, <!--fig:openspp.read.undecidable-->3<!--/fig--> undecidable, <!--fig:openspp.unread-->3<!--/fig--> not yet read |
616
  | …the two `depends`/`constrains` rules alone | **<!--fig:cls.openspp.true-->87<!--/fig--> true**, <!--fig:cls.openspp.false-->15<!--/fig--> false, <!--fig:cls.openspp.undecidable-->3<!--/fig--> undecidable: <!--fig:cls.openspp.precision-->85<!--/fig-->% |
617
  | …the 8 composed-in Python rules alone | **<!--fig:cls.python.true-->9<!--/fig--> true**, <!--fig:cls.python.false-->42<!--/fig--> false: <!--fig:cls.python.precision-->18<!--/fig-->%, after 18 datetime verdicts were reversed: Odoo runs in UTC |
618
  | …`sudo-bypasses-record-rules` alone, before it was narrowed | <!--fig:cls.openspp-rest.sudo-bypasses-record-rules.true-->6<!--/fig--> true, <!--fig:cls.openspp-rest.sudo-bypasses-record-rules.false-->97<!--/fig--> false — 5.8%, and the reason it is now an AST pass |
619
+ | Hit rate on <!--fig:core.lines-->559,663<!--/fig--> lines of odoo core 18.0 | <!--fig:core.rate-->4.2<!--/fig--> per 1000 lines: <!--fig:core.findings-->2,358<!--/fig--> findings, <!--fig:core.read-->89<!--/fig--> of them read by hand: <!--fig:core.read.true-->45<!--/fig--> true, <!--fig:core.read.false-->44<!--/fig--> false |
620
+ | Hand-classified true positives still reported | **<!--fig:survival.found-->288<!--/fig--> of <!--fig:survival.true-->288<!--/fig-->**, across <!--fig:survival.artifacts-->9<!--/fig--> classifications |
621
  | **Recall against real Odoo bugfixes** | **<!--fig:recall.all.caught-->20<!--/fig--> of <!--fig:recall.all.in_scope-->334<!--/fig-->, <!--fig:recall.all.pct-->6<!--/fig-->%, across three ecosystems. See below.** |
622
  | Verdict stability, 12 questions x 5 runs | 12/12 same verdict every run |
623
  | Escalation rescued a bad draft | 11 of 21, 95% CI 32–72% |
 
743
  The check that makes all of this safe is the same one every time, and it is worth
744
  stating as a number rather than a promise: across all
745
  <!--fig:survival.artifacts-->9<!--/fig--> classification artifacts,
746
+ **<!--fig:survival.true-->288<!--/fig--> hand-classified true positives,
747
+ <!--fig:survival.found-->288<!--/fig--> still found, none lost.** That is asserted by
748
  `python bin/figures.py --check`, which matches every `true` verdict to a finding in the
749
  committed fieldtest artifact for the same checkout at the same commit, and runs in CI.
750
  It is not done by reading the diff and hoping, and no longer by re-running against a
 
1055
  | `stored-compute-needs-complete-depends` | **11/15** | 49/57 OCA |
1056
  | `assert-is-not-validation` | **3/15** | 6/10 OCA |
1057
  | `sudo-bypasses-record-rules` | **0/15** | 6 true on OpenSPP |
1058
+ | `constrains-must-list-every-field-read` | **<!--fig:cls.odoo-core.constrains-must-list-every-field-read.true-->10<!--/fig-->/15** | <!--fig:cls.openspp.constrains-must-list-every-field-read.true-->38<!--/fig-->/<!--fig:cls.openspp.constrains-must-list-every-field-read.rows-->45<!--/fig--> OpenSPP, <!--fig:cls.oca.constrains-must-list-every-field-read.true-->8<!--/fig-->/<!--fig:cls.oca.constrains-must-list-every-field-read.rows-->8<!--/fig--> OCA |
1059
  | `depends-must-not-name-unread-fields` | **<!--fig:cls.odoo-core.depends-must-not-name-unread-fields.true-->5<!--/fig-->/15** | <!--fig:cls.openspp-rest.depends-must-not-name-unread-fields.true-->10<!--/fig-->/<!--fig:cls.openspp-rest.depends-must-not-name-unread-fields.rows-->11<!--/fig--> OpenSPP, <!--fig:cls.oca.depends-must-not-name-unread-fields.true-->3<!--/fig-->/<!--fig:cls.oca.depends-must-not-name-unread-fields.rows-->3<!--/fig--> OCA |
1060
  | `naive-and-aware-datetimes-do-not-compare` | **<!--fig:cls.odoo-core.naive-and-aware-datetimes-do-not-compare.true-->0<!--/fig-->/15** | 3/33 after the re-read below |
1061
 
 
1087
  `sudo(False)` one above: a model with `_auto = False` is a SQL view with no foreign key,
1088
  so `ondelete` is not a decision about it; `sudo(flag=False)` is de-escalation by
1089
  keyword; and `x.sudo(self.env.su)` restores the ambient level and cannot raise
1090
+ privilege. Core is now **<!--fig:core.findings-->2,358<!--/fig--> findings at
1091
  <!--fig:core.rate-->4.2<!--/fig--> per 1000 lines**.
1092
 
1093
  One finding is worth acting on outside this project. Core's
 
1101
  read, and the <!--fig:cls.sudo-core.rows-->45<!--/fig--> `sudo` findings a stricter
1102
  re-scoping test added were read in full: <!--fig:cls.sudo-core.true-->1<!--/fig--> true,
1103
  <!--fig:cls.sudo-core.false-->44<!--/fig--> false. Core makes
1104
+ <!--fig:core.findings-->2,358<!--/fig--> live ones. The rest stay unread,
1105
  and a rate on core is a statement about how often the rules fire there, not how often
1106
  they are right.
1107
 
bin/semantics.py CHANGED
@@ -154,20 +154,45 @@ def _attr_path(node, aliases):
154
  rec.line_ids.price with rec bound to self yields ('line_ids', 'price').
155
  Returns None when the base is not something known to be self-derived, which
156
  is the honest answer for a name the checker never saw bound.
 
 
 
 
 
 
157
  """
158
  segments = []
159
  cursor = node
160
  while isinstance(cursor, ast.Attribute):
161
  segments.append(cursor.attr)
162
  cursor = cursor.value
163
- if not isinstance(cursor, ast.Name):
 
 
 
 
164
  return None
165
- base = aliases.get(cursor.id)
166
  if base is None:
167
  return None
168
  return base + tuple(reversed(segments))
169
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def _aliases(fn):
172
  """Names that stand for a recordset, and the path each reaches it by.
173
 
@@ -200,14 +225,17 @@ def _aliases(fn):
200
  def _resolve_iterable(node, aliases):
201
  """The recordset path an expression iterates over, if it is one.
202
 
203
- Unwraps the calls that return a recordset of the same shape. mapped() is
204
- excluded on purpose: it returns values, not records, and treating it as a
205
- recordset would invent dependencies that do not exist.
 
206
  """
207
  if isinstance(node, ast.Name):
208
  return aliases.get(node.id)
209
  if isinstance(node, ast.Attribute):
210
  return _attr_path(node, aliases)
 
 
211
  if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
212
  if node.func.attr in ("filtered", "sorted", "exists", "with_context", "sudo"):
213
  return _resolve_iterable(node.func.value, aliases)
@@ -325,8 +353,8 @@ def _field_reads(fn, aliases):
325
  return reads
326
 
327
 
328
- def _mapped_reads(fn, aliases):
329
- """Field paths read through mapped() with a literal string, as (path, lineno).
330
 
331
  account.invoice_ids.mapped("amount_residual") reads invoice_ids.amount_residual
332
  exactly as sum(inv.amount_residual for inv in account.invoice_ids) does, but
@@ -334,35 +362,125 @@ def _mapped_reads(fn, aliases):
334
  first spelling looked fully declared by @api.depends("invoice_ids") and the
335
  second did not, so the checker was silent on the more idiomatic of the two.
336
 
337
- Only a literal string is followed. mapped(lambda l: ...) and mapped(name)
338
- stay unread: the fields a lambda touches or a variable holds are a guess
339
- from here, and every guess in this checker is a finding somebody has to
340
- read. The receiver resolves the way a loop's iterable does -- through
341
- filtered(), sorted() and sudo() -- and a mapped() inside a raise or a log
342
- line is phrasing a message, as it is for an attribute read.
 
 
 
 
 
 
343
  """
344
  quiet = _message_only_attrs(fn)
345
  reads = []
346
  for node in ast.walk(fn):
347
- if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
348
- and node.func.attr == "mapped" and len(node.args) == 1
349
- and not node.keywords) or id(node) in quiet:
350
  continue
351
- spec = node.args[0]
352
- if not (isinstance(spec, ast.Constant) and isinstance(spec.value, str)):
353
- continue
354
- segments = tuple(spec.value.strip().split("."))
355
- if not all(segment.isidentifier() for segment in segments):
356
  continue
357
  base = _resolve_iterable(node.func.value, aliases)
358
  if base is None:
359
  continue
360
- path = base + segments
361
- if path[0] not in ORM_ATTRS and not path[0].startswith("_"):
362
- reads.append((path, node.lineno))
 
363
  return reads
364
 
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  # A decorator that is present and cannot be read statically is neither absent
367
  # nor empty. @api.constrains(*FIELDS) used to collapse into "declares nothing",
368
  # which made every field the body reads a missing declaration and phrased it
@@ -445,6 +563,14 @@ def constrains_lists_every_field_read(code, gaps=None):
445
  asymmetry with @api.depends is its own rule
446
  (odoo.constrains-ignores-dotted-names, constrains_declares_a_dotted_name
447
  below); this checker only respects it.
 
 
 
 
 
 
 
 
448
  """
449
  tree, _ = parse_python(code)
450
  if tree is None:
@@ -459,7 +585,8 @@ def constrains_lists_every_field_read(code, gaps=None):
459
  continue
460
  registered = {d.split(".")[0] for d in declared}
461
  missing = {}
462
- for path, lineno in _field_reads(fn, _aliases(fn)):
 
463
  if path[0] not in registered:
464
  missing.setdefault(path[0], lineno)
465
  names = ", ".join(sorted(registered))
@@ -594,11 +721,15 @@ def depends_covers_compute_reads(code, gaps=None):
594
  file's evidence is used when it exists and the rule's assumption stands when
595
  it does not: proven-not-stored is dropped, unknown stays a finding.
596
 
597
- Reads are attribute accesses plus mapped() with a literal path
598
- (_mapped_reads), and every exemption above applies to both. Before that,
599
- sum(rec.invoice_ids.mapped("amount_residual")) under a bare
600
  @api.depends("invoice_ids") was silent while the same read spelled as a
601
- generator was reported. Core 2,336 -> 2,345; all 9 read, 8 true.
 
 
 
 
602
  """
603
  tree, _ = parse_python(code)
604
  if tree is None:
@@ -643,7 +774,7 @@ def depends_covers_compute_reads(code, gaps=None):
643
  continue
644
  missing = {}
645
  aliases = _aliases(fn)
646
- for path, lineno in _field_reads(fn, aliases) + _mapped_reads(fn, aliases):
647
  while len(path) > 1 and path[-1] in ORM_ATTRS:
648
  path = path[:-1]
649
  if path[0] in produced:
@@ -2070,7 +2201,9 @@ def depends_names_unread_fields(code):
2070
  depends_covers_compute_reads -- reporting it twice from opposite
2071
  directions is two findings about one confusion. It is also how a compute
2072
  reading another stored compute looks from here, and those declare the
2073
- upstream source on purpose.
 
 
2074
  """
2075
  tree, _ = parse_python(code)
2076
  if tree is None:
@@ -2089,7 +2222,8 @@ def depends_names_unread_fields(code):
2089
  # file cannot tell intent, so that shape stays a finding.
2090
  continue
2091
  aliases = _aliases(fn)
2092
- read = set(".".join(path) for path, _ in _field_reads(fn, aliases))
 
2093
  produced = set(_assigned_attrs(fn))
2094
  if _reads_outside_the_declarations(read - produced, declared):
2095
  continue
 
154
  rec.line_ids.price with rec bound to self yields ('line_ids', 'price').
155
  Returns None when the base is not something known to be self-derived, which
156
  is the honest answer for a name the checker never saw bound.
157
+
158
+ A chain may start from records picked by position. rec.line_ids[0].price and
159
+ rec.line_ids.sorted("sequence")[:1].price both yield ('line_ids', 'price'),
160
+ because indexing or slicing a recordset gives records of the same path.
161
+ Stopping at the subscript left the price unread, and a compute that takes
162
+ its value from the first line looked as if it read nothing from it.
163
  """
164
  segments = []
165
  cursor = node
166
  while isinstance(cursor, ast.Attribute):
167
  segments.append(cursor.attr)
168
  cursor = cursor.value
169
+ if isinstance(cursor, ast.Subscript) and _indexes_records(cursor):
170
+ base = _resolve_iterable(cursor.value, aliases)
171
+ elif isinstance(cursor, ast.Name):
172
+ base = aliases.get(cursor.id)
173
+ else:
174
  return None
 
175
  if base is None:
176
  return None
177
  return base + tuple(reversed(segments))
178
 
179
 
180
+ def _indexes_records(subscript):
181
+ """Does this subscript pick records by position: x[0], x[-1], x[1:]?
182
+
183
+ record[name] is the other thing a subscript on a recordset does, and it
184
+ reads the field called name, not records of the same path. So a subscript is
185
+ looked through only when its index is a literal integer or a slice.
186
+ rec.line_ids[i] could be either, and stays unresolved.
187
+ """
188
+ index = subscript.slice
189
+ if isinstance(index, ast.Slice):
190
+ return True
191
+ if isinstance(index, ast.UnaryOp) and isinstance(index.op, ast.USub):
192
+ index = index.operand
193
+ return isinstance(index, ast.Constant) and type(index.value) is int
194
+
195
+
196
  def _aliases(fn):
197
  """Names that stand for a recordset, and the path each reaches it by.
198
 
 
225
  def _resolve_iterable(node, aliases):
226
  """The recordset path an expression iterates over, if it is one.
227
 
228
+ Unwraps the calls that return a recordset of the same shape, and a subscript
229
+ by position, which picks records out of one. mapped() is excluded on
230
+ purpose: it returns values, not records, and treating it as a recordset
231
+ would invent dependencies that do not exist.
232
  """
233
  if isinstance(node, ast.Name):
234
  return aliases.get(node.id)
235
  if isinstance(node, ast.Attribute):
236
  return _attr_path(node, aliases)
237
+ if isinstance(node, ast.Subscript) and _indexes_records(node):
238
+ return _resolve_iterable(node.value, aliases)
239
  if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
240
  if node.func.attr in ("filtered", "sorted", "exists", "with_context", "sudo"):
241
  return _resolve_iterable(node.func.value, aliases)
 
353
  return reads
354
 
355
 
356
+ def _named_reads(fn, aliases):
357
+ """Field paths a recordset method names in a string, as (path, lineno).
358
 
359
  account.invoice_ids.mapped("amount_residual") reads invoice_ids.amount_residual
360
  exactly as sum(inv.amount_residual for inv in account.invoice_ids) does, but
 
362
  first spelling looked fully declared by @api.depends("invoice_ids") and the
363
  second did not, so the checker was silent on the more idiomatic of the two.
364
 
365
+ mapped() was the first of these (ARCHITECTURE 3.66). Its siblings name what
366
+ they read the same way: filtered("a.b") tests a.b on every record,
367
+ grouped("f") keys on f, sorted("f") orders on f, and filtered_domain() reads
368
+ the field of every leaf written out in its domain. _named_paths says which
369
+ paths each call names.
370
+
371
+ Only literals are followed. filtered(lambda l: ...) and mapped(name) stay
372
+ unread: the fields a lambda touches or a variable holds are a guess from
373
+ here, and every guess in this checker is a finding somebody has to read.
374
+ The receiver resolves the way a loop's iterable does, through filtered(),
375
+ sorted(), sudo() and a subscript by position. A call inside a raise or a
376
+ log line is phrasing a message, as an attribute read there is.
377
  """
378
  quiet = _message_only_attrs(fn)
379
  reads = []
380
  for node in ast.walk(fn):
381
+ if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)) \
382
+ or id(node) in quiet:
 
383
  continue
384
+ named = _named_paths(node)
385
+ if not named:
 
 
 
386
  continue
387
  base = _resolve_iterable(node.func.value, aliases)
388
  if base is None:
389
  continue
390
+ for segments in named:
391
+ path = base + segments
392
+ if path[0] not in ORM_ATTRS and not path[0].startswith("_"):
393
+ reads.append((path, node.lineno))
394
  return reads
395
 
396
 
397
+ # Recordset methods whose one argument can be a field path. mapped("a.b") and
398
+ # filtered("a.b") follow it through the relations; grouped("f") keys on f.
399
+ PATH_ARGUMENT_METHODS = frozenset(["mapped", "filtered", "grouped"])
400
+
401
+
402
+ def _named_paths(call):
403
+ """The field paths, relative to its receiver, that a recordset call names."""
404
+ method = call.func.attr
405
+ if method in PATH_ARGUMENT_METHODS:
406
+ if len(call.args) != 1 or call.keywords:
407
+ return []
408
+ path = _literal_path(call.args[0])
409
+ return [path] if path else []
410
+ if method == "sorted":
411
+ keys = list(call.args[:1]) + [k.value for k in call.keywords if k.arg == "key"]
412
+ if len(keys) == 1 and isinstance(keys[0], ast.Constant) \
413
+ and isinstance(keys[0].value, str):
414
+ return _order_fields(keys[0].value)
415
+ return []
416
+ if method == "filtered_domain":
417
+ if len(call.args) == 1 and not call.keywords \
418
+ and isinstance(call.args[0], (ast.List, ast.Tuple)):
419
+ return _domain_fields(call.args[0])
420
+ return []
421
+
422
+
423
+ def _literal_path(node):
424
+ """('a', 'b') for the string literal "a.b"; None for anything else."""
425
+ if not (isinstance(node, ast.Constant) and isinstance(node.value, str)):
426
+ return None
427
+ return _path_segments(node.value)
428
+
429
+
430
+ def _path_segments(text):
431
+ """('a', 'b') for "a.b"; None when the text is not a dotted field path."""
432
+ segments = tuple(text.strip().split("."))
433
+ return segments if all(segment.isidentifier() for segment in segments) else None
434
+
435
+
436
+ ORDER_WORDS = frozenset(["asc", "desc", "nulls", "first", "last"])
437
+
438
+
439
+ def _order_fields(spec):
440
+ """The field paths an order sorts on: "date desc, id" names date and id.
441
+
442
+ Odoo 18 takes one field name here, as itemgetter(key). Odoo 19 takes an
443
+ order: comma-separated terms, each a field with an optional .property and
444
+ :granularity, then asc or desc and nulls first or last
445
+ (_sorted_order_to_function in odoo/orm/models.py, 19.0). A spec with any
446
+ other word in it is read not at all rather than in part.
447
+ """
448
+ paths = []
449
+ for term in spec.split(","):
450
+ words = term.split()
451
+ if not words or any(word.lower() not in ORDER_WORDS for word in words[1:]):
452
+ return []
453
+ path = _path_segments(words[0].split(":")[0])
454
+ if path is None:
455
+ return []
456
+ paths.append(path)
457
+ return paths
458
+
459
+
460
+ def _domain_fields(domain):
461
+ """The field path of every leaf a domain literal writes out.
462
+
463
+ [("state", "=", "done"), ("partner_id.country_id", "in", ids)] names state and
464
+ partner_id.country_id. A leaf whose operator is any or not any holds a
465
+ domain on the related records, read through the leaf's own field:
466
+ ("line_ids", "any", [("paid", "=", True)]) names line_ids and line_ids.paid.
467
+ "&", "|" and "!" name nothing, and neither does a leaf built at run time.
468
+ """
469
+ paths = []
470
+ for leaf in domain.elts:
471
+ if not (isinstance(leaf, (ast.Tuple, ast.List)) and len(leaf.elts) == 3):
472
+ continue
473
+ field, operator, value = leaf.elts
474
+ path = _literal_path(field)
475
+ if path is None:
476
+ continue
477
+ paths.append(path)
478
+ if isinstance(operator, ast.Constant) and operator.value in ("any", "not any") \
479
+ and isinstance(value, (ast.List, ast.Tuple)):
480
+ paths.extend(path + inner for inner in _domain_fields(value))
481
+ return paths
482
+
483
+
484
  # A decorator that is present and cannot be read statically is neither absent
485
  # nor empty. @api.constrains(*FIELDS) used to collapse into "declares nothing",
486
  # which made every field the body reads a missing declaration and phrased it
 
563
  asymmetry with @api.depends is its own rule
564
  (odoo.constrains-ignores-dotted-names, constrains_declares_a_dotted_name
565
  below); this checker only respects it.
566
+
567
+ Reads are the ones the depends check makes: attribute accesses, including
568
+ one after a subscript by position, and the fields a recordset method names
569
+ in a string (_named_reads). `if any(self.mapped("date_end"))` under
570
+ @api.constrains("date_start") was silent. Measured before it was taken: OCA
571
+ +1, OpenSPP 0, core +3, all read, 3 true. The false one filters on a
572
+ non-stored compute, which @api.constrains cannot name: a class this rule
573
+ already had for attribute reads (ARCHITECTURE 3.71).
574
  """
575
  tree, _ = parse_python(code)
576
  if tree is None:
 
585
  continue
586
  registered = {d.split(".")[0] for d in declared}
587
  missing = {}
588
+ aliases = _aliases(fn)
589
+ for path, lineno in _field_reads(fn, aliases) + _named_reads(fn, aliases):
590
  if path[0] not in registered:
591
  missing.setdefault(path[0], lineno)
592
  names = ", ".join(sorted(registered))
 
721
  file's evidence is used when it exists and the rule's assumption stands when
722
  it does not: proven-not-stored is dropped, unknown stays a finding.
723
 
724
+ Reads are attribute accesses plus the fields a recordset method names in a
725
+ string (_named_reads), and every exemption above applies to both. mapped()
726
+ came first: sum(rec.invoice_ids.mapped("amount_residual")) under a bare
727
  @api.depends("invoice_ids") was silent while the same read spelled as a
728
+ generator was reported. Core 2,336 -> 2,345; all 9 read, 8 true. filtered(),
729
+ sorted(), grouped() and filtered_domain() followed, with a read after a
730
+ subscript by position (rec.line_ids[0].amount, _attr_path): OCA +1, OpenSPP
731
+ +6, core +17, all 24 read, 15 true. The 9 false are classes this checker
732
+ already had for attribute reads (ARCHITECTURE 3.71).
733
  """
734
  tree, _ = parse_python(code)
735
  if tree is None:
 
774
  continue
775
  missing = {}
776
  aliases = _aliases(fn)
777
+ for path, lineno in _field_reads(fn, aliases) + _named_reads(fn, aliases):
778
  while len(path) > 1 and path[-1] in ORM_ATTRS:
779
  path = path[:-1]
780
  if path[0] in produced:
 
2201
  depends_covers_compute_reads -- reporting it twice from opposite
2202
  directions is two findings about one confusion. It is also how a compute
2203
  reading another stored compute looks from here, and those declare the
2204
+ upstream source on purpose. "Reads" here are the ones that rule makes,
2205
+ fields named in a string (_named_reads) among them, so the two agree
2206
+ about what the body reads.
2207
  """
2208
  tree, _ = parse_python(code)
2209
  if tree is None:
 
2222
  # file cannot tell intent, so that shape stays a finding.
2223
  continue
2224
  aliases = _aliases(fn)
2225
+ read = set(".".join(path) for path, _ in
2226
+ _field_reads(fn, aliases) + _named_reads(fn, aliases))
2227
  produced = set(_assigned_attrs(fn))
2228
  if _reads_outside_the_declarations(read - produced, declared):
2229
  continue
classification-oca-2026-09.json CHANGED
@@ -13,7 +13,7 @@
13
  },
14
  "totals": {
15
  "false": 11,
16
- "true": 78,
17
  "undecidable": 13
18
  },
19
  "classes": {
@@ -944,7 +944,25 @@
944
  "class": "registry-model",
945
  "why": "search_read on ir.model.fields, which has no company_id and so no tenant boundary",
946
  "evidence": null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
947
  }
948
  ],
949
- "note": "Re-measured after sudo-bypasses-record-rules became an AST pass and suppression learned to read a marker across the statement it sits in. Three findings are new: the AST pass also sees .sudo().search_count( and .sudo().search_read(, which the regex could not, and all three are false. One false positive left -- a sql-needs-query-parameters in base_multi_image/hooks.py that carries a marker on the line above it. All 79 true positives survived both changes.\n\n2026-09-26: 1 recursion-check-on-hierarchy verdict(s) reversed from true to false, class parent-store-refuses-the-cycle. Reading spp_area behind a true verdict showed the model sets _parent_store = True, which makes the ORM refuse the cycle itself; every other true verdict for the rule was checked the same way. The rule moved to an AST pass (semantics.parent_can_form_a_cycle) that asks whether the field points at its own model and whether _parent_store already guards it. Each reversed row keeps was: true."
950
  }
 
13
  },
14
  "totals": {
15
  "false": 11,
16
+ "true": 80,
17
  "undecidable": 13
18
  },
19
  "classes": {
 
944
  "class": "registry-model",
945
  "why": "search_read on ir.model.fields, which has no company_id and so no tenant boundary",
946
  "evidence": null
947
+ },
948
+ {
949
+ "rule": "odoo.stored-compute-needs-complete-depends",
950
+ "path": "account-financial-tools/account_move_name_sequence/models/account_move.py",
951
+ "line": 106,
952
+ "verdict": "true",
953
+ "class": null,
954
+ "why": "made_sequence_gap is stored in account and set to False here for moves outside a hashed journal, and neither this decorator nor account's own names journal_id.restrict_mode_hash_table (Odoo merges the two). Securing a journal leaves its existing moves on False.",
955
+ "evidence": "_compute_made_sequence_gap() reads 'journal_id.restrict_mode_hash_table' but @api.depends does not declare it -- the stored value goes stale when 'journal_id.restrict_mode_hash_table' changes"
956
+ },
957
+ {
958
+ "rule": "odoo.constrains-must-list-every-field-read",
959
+ "path": "account-financial-tools/account_spread_cost_revenue/models/account_spread.py",
960
+ "line": 303,
961
+ "verdict": "true",
962
+ "class": null,
963
+ "why": "the check reads the moves reached through line_ids and declares only journal_id, so a write of line_ids alone is not checked. True as the rule states it; every move the module creates for a line takes the spread's own journal.",
964
+ "evidence": "_check_journal() reads 'line_ids' but @api.constrains declares only journal_id -- writing 'line_ids' alone skips the check entirely"
965
  }
966
  ],
967
+ "note": "Re-measured after sudo-bypasses-record-rules became an AST pass and suppression learned to read a marker across the statement it sits in. Three findings are new: the AST pass also sees .sudo().search_count( and .sudo().search_read(, which the regex could not, and all three are false. One false positive left -- a sql-needs-query-parameters in base_multi_image/hooks.py that carries a marker on the line above it. All 79 true positives survived both changes.\n\n2026-09-26: 1 recursion-check-on-hierarchy verdict(s) reversed from true to false, class parent-store-refuses-the-cycle. Reading spp_area behind a true verdict showed the model sets _parent_store = True, which makes the ORM refuse the cycle itself; every other true verdict for the rule was checked the same way. The rule moved to an AST pass (semantics.parent_can_form_a_cycle) that asks whether the field points at its own model and whether _parent_store already guards it. Each reversed row keeps was: true.\n\n2026-09-27: 2 findings added and read, both true, surfaced when the depends and constrains checks started reading the fields a recordset method names in a string (ARCHITECTURE 3.71): account_move_name_sequence filters on journal_id.restrict_mode_hash_table, and account_spread_cost_revenue's journal check reads mapped(\"line_ids.move_id\")."
968
  }
classification-openspp-rest-2026-09.json CHANGED
@@ -11,7 +11,7 @@
11
  "files": 1351,
12
  "lines": 226937,
13
  "totals": {
14
- "true": 49,
15
  "false": 115,
16
  "undecidable": 3
17
  },
@@ -49,6 +49,9 @@
49
  },
50
  "odoo.commit-inside-a-loop-splits-the-transaction": {
51
  "true": 1
 
 
 
52
  }
53
  },
54
  "false_classes": {
@@ -1677,8 +1680,62 @@
1677
  "verdict": "true",
1678
  "class": null,
1679
  "why": "one head per group is checked here and in the group's write(); spp_change_request_v2's add_member creates memberships directly, past both"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1680
  }
1681
  ],
1682
- "what_this_changed": "sudo-bypasses-record-rules went 6 true / 97 false / 3 undecidable, 5.8%. 73 of the 97 were on models the tree gives no ir.rule at all and 10 on a model whose only rule is [(1,'=',1)], so sudo dropped nothing at 83 of 106 sites. The rule is now an AST pass that asks the tree whether a filtering rule exists, and answers UNKNOWN -- keeping the finding -- for any model the walk did not see declared. The 14 remaining false positives constrain the search by hand to records the caller already named, in a spelling the rule does not recognise because it names only company_id. That is a worklist entry, not a narrowing: deciding a domain is already scoped means evaluating it.\n\n2026-09-26: 5 recursion-check-on-hierarchy verdict(s) reversed from true to false, class parent-store-refuses-the-cycle. Reading spp_area behind a true verdict showed the model sets _parent_store = True, which makes the ORM refuse the cycle itself; every other true verdict for the rule was checked the same way. The rule moved to an AST pass (semantics.parent_can_form_a_cycle) that asks whether the field points at its own model and whether _parent_store already guards it. Each reversed row keeps was: true.\n\n2026-09-27: 4 onchange-is-not-validation findings added and read, surfaced when the checker stopped letting any constraint in the file clear it and started asking whether a constraint covers a field the onchange watches (ARCHITECTURE 3.68). 3 true, 1 false (backed-by-the-parent-constraint).",
1683
  "worklist": "float-comparison-needs-float-utils is the next one down at 2 true / 5 false. All five compare an INTEGER to zero -- search_count, a read_group _count, SELECT COUNT(*) -- which the regex cannot see because it matches on the variable's name. Fixing it means tracing the assignment, so it is named here and not yet done. onchange-is-not-validation is 4 true / 3 false, and all three false ones are an onchange that sets a field rather than enforcing anything, which the rule's existing narrowing was supposed to exclude and does not catch when the write is to a Many2one."
1684
  }
 
11
  "files": 1351,
12
  "lines": 226937,
13
  "totals": {
14
+ "true": 55,
15
  "false": 115,
16
  "undecidable": 3
17
  },
 
49
  },
50
  "odoo.commit-inside-a-loop-splits-the-transaction": {
51
  "true": 1
52
+ },
53
+ "odoo.stored-compute-needs-complete-depends": {
54
+ "true": 6
55
  }
56
  },
57
  "false_classes": {
 
1680
  "verdict": "true",
1681
  "class": null,
1682
  "why": "one head per group is checked here and in the group's write(); spp_change_request_v2's add_member creates memberships directly, past both"
1683
+ },
1684
+ {
1685
+ "rule": "odoo.stored-compute-needs-complete-depends",
1686
+ "path": "spp_approval/models/approval_review_multitier.py",
1687
+ "line": 53,
1688
+ "evidence": "_compute_current_tier() reads 'tier_review_ids.sequence' but @api.depends does not declare it -- the stored value goes stale when 'tier_review_ids.seq",
1689
+ "verdict": "true",
1690
+ "class": null,
1691
+ "why": "current_tier_id is stored and takes the first pending tier by sequence, and the decorator names only tier_review_ids.status. A tier review's sequence is a stored related of its tier's, so reordering a definition's tiers while reviews are pending leaves each review on the tier the old order put first."
1692
+ },
1693
+ {
1694
+ "rule": "odoo.stored-compute-needs-complete-depends",
1695
+ "path": "spp_programs/models/registrant.py",
1696
+ "line": 54,
1697
+ "evidence": "_compute_cycle_membership_count() reads 'is_registrant' but @api.depends does not declare it -- the stored value goes stale when 'is_registrant' chang",
1698
+ "verdict": "true",
1699
+ "class": null,
1700
+ "why": "the stored count is set to 0 for every partner self.filtered(\"is_registrant\") leaves out, and the decorator does not name is_registrant, so a partner whose flag changes keeps the count it had. True as the rule states it; every create path in the tree sets is_registrant at creation and nothing rewrites it."
1701
+ },
1702
+ {
1703
+ "rule": "odoo.stored-compute-needs-complete-depends",
1704
+ "path": "spp_programs/models/registrant.py",
1705
+ "line": 96,
1706
+ "evidence": "_compute_program_membership_count() reads 'is_registrant' but @api.depends does not declare it -- the stored value goes stale when 'is_registrant' cha",
1707
+ "verdict": "true",
1708
+ "class": null,
1709
+ "why": "the stored count is set to 0 for every partner self.filtered(\"is_registrant\") leaves out, and the decorator does not name is_registrant, so a partner whose flag changes keeps the count it had. True as the rule states it; every create path in the tree sets is_registrant at creation and nothing rewrites it."
1710
+ },
1711
+ {
1712
+ "rule": "odoo.stored-compute-needs-complete-depends",
1713
+ "path": "spp_programs/models/registrant.py",
1714
+ "line": 121,
1715
+ "evidence": "_compute_entitlements_count() reads 'is_registrant' but @api.depends does not declare it -- the stored value goes stale when 'is_registrant' changes",
1716
+ "verdict": "true",
1717
+ "class": null,
1718
+ "why": "the stored count is set to 0 for every partner self.filtered(\"is_registrant\") leaves out, and the decorator does not name is_registrant, so a partner whose flag changes keeps the count it had. True as the rule states it; every create path in the tree sets is_registrant at creation and nothing rewrites it."
1719
+ },
1720
+ {
1721
+ "rule": "odoo.stored-compute-needs-complete-depends",
1722
+ "path": "spp_programs/models/registrant.py",
1723
+ "line": 146,
1724
+ "evidence": "_compute_cycle_count() reads 'is_registrant' but @api.depends does not declare it -- the stored value goes stale when 'is_registrant' changes",
1725
+ "verdict": "true",
1726
+ "class": null,
1727
+ "why": "the stored count is set to 0 for every partner self.filtered(\"is_registrant\") leaves out, and the decorator does not name is_registrant, so a partner whose flag changes keeps the count it had. True as the rule states it; every create path in the tree sets is_registrant at creation and nothing rewrites it."
1728
+ },
1729
+ {
1730
+ "rule": "odoo.stored-compute-needs-complete-depends",
1731
+ "path": "spp_programs/models/registrant.py",
1732
+ "line": 171,
1733
+ "evidence": "_compute_inkind_entitlements_count() reads 'is_registrant' but @api.depends does not declare it -- the stored value goes stale when 'is_registrant' ch",
1734
+ "verdict": "true",
1735
+ "class": null,
1736
+ "why": "the stored count is set to 0 for every partner self.filtered(\"is_registrant\") leaves out, and the decorator does not name is_registrant, so a partner whose flag changes keeps the count it had. True as the rule states it; every create path in the tree sets is_registrant at creation and nothing rewrites it."
1737
  }
1738
  ],
1739
+ "what_this_changed": "sudo-bypasses-record-rules went 6 true / 97 false / 3 undecidable, 5.8%. 73 of the 97 were on models the tree gives no ir.rule at all and 10 on a model whose only rule is [(1,'=',1)], so sudo dropped nothing at 83 of 106 sites. The rule is now an AST pass that asks the tree whether a filtering rule exists, and answers UNKNOWN -- keeping the finding -- for any model the walk did not see declared. The 14 remaining false positives constrain the search by hand to records the caller already named, in a spelling the rule does not recognise because it names only company_id. That is a worklist entry, not a narrowing: deciding a domain is already scoped means evaluating it.\n\n2026-09-26: 5 recursion-check-on-hierarchy verdict(s) reversed from true to false, class parent-store-refuses-the-cycle. Reading spp_area behind a true verdict showed the model sets _parent_store = True, which makes the ORM refuse the cycle itself; every other true verdict for the rule was checked the same way. The rule moved to an AST pass (semantics.parent_can_form_a_cycle) that asks whether the field points at its own model and whether _parent_store already guards it. Each reversed row keeps was: true.\n\n2026-09-27: 4 onchange-is-not-validation findings added and read, surfaced when the checker stopped letting any constraint in the file clear it and started asking whether a constraint covers a field the onchange watches (ARCHITECTURE 3.68). 3 true, 1 false (backed-by-the-parent-constraint).\n\n2026-09-27: 6 stored-compute-needs-complete-depends findings added and read, surfaced when the depends check started reading the fields a recordset method names in a string (filtered(\"is_registrant\"), sorted(\"sequence\")) as it already read mapped(\"a.b\") (ARCHITECTURE 3.71). All 6 true. They are recorded here, with the other findings later changes surfaced, rather than in classification-openspp-2026-09.json, which is the reading of the two flagship rules made on 2026-09-16 and is quoted as that reading.",
1740
  "worklist": "float-comparison-needs-float-utils is the next one down at 2 true / 5 false. All five compare an INTEGER to zero -- search_count, a read_group _count, SELECT COUNT(*) -- which the regex cannot see because it matches on the variable's name. Fixing it means tracing the assignment, so it is named here and not yet done. onchange-is-not-validation is 4 true / 3 false, and all three false ones are an onchange that sets a field rather than enforcing anything, which the rule's existing narrowing was supposed to exclude and does not catch when the write is to a Many2one."
1741
  }
fieldtest-oca-2026-09.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "generated_utc": "2026-09-27T16:00:57+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "server-tools",
@@ -873,6 +873,16 @@
873
  "likelihood": "unproven",
874
  "path": "account-financial-tools/account_move_name_sequence/models/account_journal.py"
875
  },
 
 
 
 
 
 
 
 
 
 
876
  {
877
  "rule": "odoo.sudo-bypasses-record-rules",
878
  "severity": "critical",
@@ -1083,6 +1093,16 @@
1083
  "likelihood": "construct",
1084
  "path": "account-financial-tools/account_spread_cost_revenue/models/account_spread.py"
1085
  },
 
 
 
 
 
 
 
 
 
 
1086
  {
1087
  "rule": "odoo.float-comparison-needs-float-utils",
1088
  "severity": "high",
 
1
  {
2
+ "generated_utc": "2026-09-27T16:45:43+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "server-tools",
 
873
  "likelihood": "unproven",
874
  "path": "account-financial-tools/account_move_name_sequence/models/account_journal.py"
875
  },
876
+ {
877
+ "rule": "odoo.stored-compute-needs-complete-depends",
878
+ "severity": "critical",
879
+ "line": 106,
880
+ "evidence": "_compute_made_sequence_gap() reads 'journal_id.restrict_mode_hash_table' but @api.depends does not declare it -- the stored value goes stale when 'journal_id.restrict_mode_hash_table' changes",
881
+ "suppressed": false,
882
+ "advisory": false,
883
+ "likelihood": "construct",
884
+ "path": "account-financial-tools/account_move_name_sequence/models/account_move.py"
885
+ },
886
  {
887
  "rule": "odoo.sudo-bypasses-record-rules",
888
  "severity": "critical",
 
1093
  "likelihood": "construct",
1094
  "path": "account-financial-tools/account_spread_cost_revenue/models/account_spread.py"
1095
  },
1096
+ {
1097
+ "rule": "odoo.constrains-must-list-every-field-read",
1098
+ "severity": "critical",
1099
+ "line": 303,
1100
+ "evidence": "_check_journal() reads 'line_ids' but @api.constrains declares only journal_id -- writing 'line_ids' alone skips the check entirely",
1101
+ "suppressed": false,
1102
+ "advisory": false,
1103
+ "likelihood": "construct",
1104
+ "path": "account-financial-tools/account_spread_cost_revenue/models/account_spread.py"
1105
+ },
1106
  {
1107
  "rule": "odoo.float-comparison-needs-float-utils",
1108
  "severity": "high",
fieldtest-odoo-core-2026-09.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "generated_utc": "2026-09-27T16:18:40+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "odoo"
@@ -1651,6 +1651,16 @@
1651
  "likelihood": "construct",
1652
  "path": "addons/account/models/account_payment_term.py"
1653
  },
 
 
 
 
 
 
 
 
 
 
1654
  {
1655
  "rule": "odoo.stored-compute-needs-complete-depends",
1656
  "severity": "critical",
@@ -2971,6 +2981,16 @@
2971
  "likelihood": "unmeasured",
2972
  "path": "addons/base_automation/models/base_automation.py"
2973
  },
 
 
 
 
 
 
 
 
 
 
2974
  {
2975
  "rule": "odoo.constrains-must-list-every-field-read",
2976
  "severity": "critical",
@@ -2981,6 +3001,16 @@
2981
  "likelihood": "construct",
2982
  "path": "addons/base_iban/models/res_partner_bank.py"
2983
  },
 
 
 
 
 
 
 
 
 
 
2984
  {
2985
  "rule": "python.assert-is-not-validation",
2986
  "severity": "critical",
@@ -5201,6 +5231,16 @@
5201
  "likelihood": "construct",
5202
  "path": "addons/hr_expense/models/hr_expense.py"
5203
  },
 
 
 
 
 
 
 
 
 
 
5204
  {
5205
  "rule": "odoo.stored-compute-needs-complete-depends",
5206
  "severity": "critical",
@@ -5421,6 +5461,16 @@
5421
  "likelihood": "construct",
5422
  "path": "addons/hr_expense/models/hr_expense_sheet.py"
5423
  },
 
 
 
 
 
 
 
 
 
 
5424
  {
5425
  "rule": "odoo.stored-compute-needs-complete-depends",
5426
  "severity": "critical",
@@ -5831,6 +5881,16 @@
5831
  "likelihood": "unproven",
5832
  "path": "addons/hr_holidays/models/hr_leave.py"
5833
  },
 
 
 
 
 
 
 
 
 
 
5834
  {
5835
  "rule": "odoo.stored-compute-needs-complete-depends",
5836
  "severity": "critical",
@@ -5841,6 +5901,16 @@
5841
  "likelihood": "construct",
5842
  "path": "addons/hr_holidays/models/hr_leave_accrual_plan_level.py"
5843
  },
 
 
 
 
 
 
 
 
 
 
5844
  {
5845
  "rule": "odoo.ondelete-is-a-decision",
5846
  "severity": "high",
@@ -6031,6 +6101,16 @@
6031
  "likelihood": "unproven",
6032
  "path": "addons/hr_holidays_attendance/models/hr_leave_allocation.py"
6033
  },
 
 
 
 
 
 
 
 
 
 
6034
  {
6035
  "rule": "odoo.depends-must-not-name-unread-fields",
6036
  "severity": "medium",
@@ -8141,6 +8221,16 @@
8141
  "likelihood": "unmeasured",
8142
  "path": "addons/l10n_id_efaktur/models/efaktur_document.py"
8143
  },
 
 
 
 
 
 
 
 
 
 
8144
  {
8145
  "rule": "python.assert-is-not-validation",
8146
  "severity": "critical",
@@ -8181,6 +8271,16 @@
8181
  "likelihood": "construct",
8182
  "path": "addons/l10n_id_efaktur_coretax/models/account_move.py"
8183
  },
 
 
 
 
 
 
 
 
 
 
8184
  {
8185
  "rule": "odoo.ondelete-is-a-decision",
8186
  "severity": "high",
@@ -9151,6 +9251,16 @@
9151
  "likelihood": "construct",
9152
  "path": "addons/l10n_latam_check/models/l10n_latam_check.py"
9153
  },
 
 
 
 
 
 
 
 
 
 
9154
  {
9155
  "rule": "odoo.stored-compute-needs-complete-depends",
9156
  "severity": "critical",
@@ -11191,6 +11301,16 @@
11191
  "likelihood": "unmeasured",
11192
  "path": "addons/mail/wizard/mail_activity_schedule.py"
11193
  },
 
 
 
 
 
 
 
 
 
 
11194
  {
11195
  "rule": "odoo.commit-inside-a-loop-splits-the-transaction",
11196
  "severity": "high",
@@ -13511,6 +13631,26 @@
13511
  "likelihood": "unmeasured",
13512
  "path": "addons/point_of_sale/models/pos_session.py"
13513
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13514
  {
13515
  "rule": "odoo.prefer-sql-constraint",
13516
  "severity": "high",
@@ -13581,6 +13721,16 @@
13581
  "likelihood": "unproven",
13582
  "path": "addons/point_of_sale/models/pos_session.py"
13583
  },
 
 
 
 
 
 
 
 
 
 
13584
  {
13585
  "rule": "odoo.sudo-bypasses-record-rules",
13586
  "severity": "critical",
@@ -17191,6 +17341,16 @@
17191
  "likelihood": "defect",
17192
  "path": "addons/sale_crm/models/sale_order.py"
17193
  },
 
 
 
 
 
 
 
 
 
 
17194
  {
17195
  "rule": "odoo.ondelete-is-a-decision",
17196
  "severity": "high",
@@ -18401,6 +18561,16 @@
18401
  "likelihood": "construct",
18402
  "path": "addons/sale_timesheet/models/hr_timesheet.py"
18403
  },
 
 
 
 
 
 
 
 
 
 
18404
  {
18405
  "rule": "odoo.stored-compute-needs-complete-depends",
18406
  "severity": "critical",
@@ -20011,6 +20181,16 @@
20011
  "likelihood": "unmeasured",
20012
  "path": "addons/stock/models/stock_scrap.py"
20013
  },
 
 
 
 
 
 
 
 
 
 
20014
  {
20015
  "rule": "odoo.ondelete-is-a-decision",
20016
  "severity": "high",
@@ -22381,6 +22561,16 @@
22381
  "likelihood": "construct",
22382
  "path": "addons/website_sale_gelato/models/product_document.py"
22383
  },
 
 
 
 
 
 
 
 
 
 
22384
  {
22385
  "rule": "odoo.constrains-must-list-every-field-read",
22386
  "severity": "critical",
@@ -22671,6 +22861,16 @@
22671
  "likelihood": "construct",
22672
  "path": "addons/website_slides/models/slide_slide.py"
22673
  },
 
 
 
 
 
 
 
 
 
 
22674
  {
22675
  "rule": "odoo.sudo-bypasses-record-rules",
22676
  "severity": "critical",
 
1
  {
2
+ "generated_utc": "2026-09-27T16:53:36+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "odoo"
 
1651
  "likelihood": "construct",
1652
  "path": "addons/account/models/account_payment_term.py"
1653
  },
1654
+ {
1655
+ "rule": "odoo.stored-compute-needs-complete-depends",
1656
+ "severity": "critical",
1657
+ "line": 356,
1658
+ "evidence": "_compute_days() reads 'payment_id.line_ids.nb_days' but @api.depends does not declare it -- the stored value goes stale when 'payment_id.line_ids.nb_days' changes",
1659
+ "suppressed": false,
1660
+ "advisory": false,
1661
+ "likelihood": "construct",
1662
+ "path": "addons/account/models/account_payment_term.py"
1663
+ },
1664
  {
1665
  "rule": "odoo.stored-compute-needs-complete-depends",
1666
  "severity": "critical",
 
2981
  "likelihood": "unmeasured",
2982
  "path": "addons/base_automation/models/base_automation.py"
2983
  },
2984
+ {
2985
+ "rule": "odoo.stored-compute-needs-complete-depends",
2986
+ "severity": "critical",
2987
+ "line": 42,
2988
+ "evidence": "_compute_name() reads 'base_automation_id' but @api.depends does not declare it -- the stored value goes stale when 'base_automation_id' changes",
2989
+ "suppressed": false,
2990
+ "advisory": false,
2991
+ "likelihood": "construct",
2992
+ "path": "addons/base_automation/models/ir_actions_server.py"
2993
+ },
2994
  {
2995
  "rule": "odoo.constrains-must-list-every-field-read",
2996
  "severity": "critical",
 
3001
  "likelihood": "construct",
3002
  "path": "addons/base_iban/models/res_partner_bank.py"
3003
  },
3004
+ {
3005
+ "rule": "odoo.stored-compute-needs-complete-depends",
3006
+ "severity": "critical",
3007
+ "line": 55,
3008
+ "evidence": "_get_icon_image() reads 'imported' but @api.depends does not declare it -- the stored value goes stale when 'imported' changes",
3009
+ "suppressed": false,
3010
+ "advisory": false,
3011
+ "likelihood": "construct",
3012
+ "path": "addons/base_import_module/models/ir_module.py"
3013
+ },
3014
  {
3015
  "rule": "python.assert-is-not-validation",
3016
  "severity": "critical",
 
5231
  "likelihood": "construct",
5232
  "path": "addons/hr_expense/models/hr_expense.py"
5233
  },
5234
+ {
5235
+ "rule": "odoo.stored-compute-needs-complete-depends",
5236
+ "severity": "critical",
5237
+ "line": 318,
5238
+ "evidence": "_compute_total_amount_currency() reads 'product_has_cost' but @api.depends does not declare it -- the stored value goes stale when 'product_has_cost' changes",
5239
+ "suppressed": false,
5240
+ "advisory": false,
5241
+ "likelihood": "construct",
5242
+ "path": "addons/hr_expense/models/hr_expense.py"
5243
+ },
5244
  {
5245
  "rule": "odoo.stored-compute-needs-complete-depends",
5246
  "severity": "critical",
 
5461
  "likelihood": "construct",
5462
  "path": "addons/hr_expense/models/hr_expense_sheet.py"
5463
  },
5464
+ {
5465
+ "rule": "odoo.stored-compute-needs-complete-depends",
5466
+ "severity": "critical",
5467
+ "line": 246,
5468
+ "evidence": "_compute_from_account_move_ids() reads 'account_move_ids.reversal_move_ids' but @api.depends does not declare it -- the stored value goes stale when 'account_move_ids.reversal_move_ids' changes",
5469
+ "suppressed": false,
5470
+ "advisory": false,
5471
+ "likelihood": "construct",
5472
+ "path": "addons/hr_expense/models/hr_expense_sheet.py"
5473
+ },
5474
  {
5475
  "rule": "odoo.stored-compute-needs-complete-depends",
5476
  "severity": "critical",
 
5881
  "likelihood": "unproven",
5882
  "path": "addons/hr_holidays/models/hr_leave.py"
5883
  },
5884
+ {
5885
+ "rule": "odoo.stored-compute-needs-complete-depends",
5886
+ "severity": "critical",
5887
+ "line": 114,
5888
+ "evidence": "_compute_added_value_type() reads 'level_ids.added_value_type' but @api.depends does not declare it -- the stored value goes stale when 'level_ids.added_value_type' changes",
5889
+ "suppressed": false,
5890
+ "advisory": false,
5891
+ "likelihood": "construct",
5892
+ "path": "addons/hr_holidays/models/hr_leave_accrual_plan.py"
5893
+ },
5894
  {
5895
  "rule": "odoo.stored-compute-needs-complete-depends",
5896
  "severity": "critical",
 
5901
  "likelihood": "construct",
5902
  "path": "addons/hr_holidays/models/hr_leave_accrual_plan_level.py"
5903
  },
5904
+ {
5905
+ "rule": "odoo.stored-compute-needs-complete-depends",
5906
+ "severity": "critical",
5907
+ "line": 186,
5908
+ "evidence": "_compute_added_value_type() reads 'accrual_plan_id.level_ids.added_value_type' but @api.depends does not declare it -- the stored value goes stale when 'accrual_plan_id.level_ids.added_value_type' changes",
5909
+ "suppressed": false,
5910
+ "advisory": false,
5911
+ "likelihood": "construct",
5912
+ "path": "addons/hr_holidays/models/hr_leave_accrual_plan_level.py"
5913
+ },
5914
  {
5915
  "rule": "odoo.ondelete-is-a-decision",
5916
  "severity": "high",
 
6101
  "likelihood": "unproven",
6102
  "path": "addons/hr_holidays_attendance/models/hr_leave_allocation.py"
6103
  },
6104
+ {
6105
+ "rule": "odoo.constrains-must-list-every-field-read",
6106
+ "severity": "critical",
6107
+ "line": 63,
6108
+ "evidence": "_check_contracts() reads 'employee_id' but @api.constrains declares only date_from, date_to -- writing 'employee_id' alone skips the check entirely",
6109
+ "suppressed": false,
6110
+ "advisory": false,
6111
+ "likelihood": "construct",
6112
+ "path": "addons/hr_holidays_contract/models/hr_leave.py"
6113
+ },
6114
  {
6115
  "rule": "odoo.depends-must-not-name-unread-fields",
6116
  "severity": "medium",
 
8221
  "likelihood": "unmeasured",
8222
  "path": "addons/l10n_id_efaktur/models/efaktur_document.py"
8223
  },
8224
+ {
8225
+ "rule": "odoo.stored-compute-needs-complete-depends",
8226
+ "severity": "critical",
8227
+ "line": 293,
8228
+ "evidence": "_compute_name() reads 'invoice_ids.name' but @api.depends does not declare it -- the stored value goes stale when 'invoice_ids.name' changes",
8229
+ "suppressed": false,
8230
+ "advisory": false,
8231
+ "likelihood": "construct",
8232
+ "path": "addons/l10n_id_efaktur/models/efaktur_document.py"
8233
+ },
8234
  {
8235
  "rule": "python.assert-is-not-validation",
8236
  "severity": "critical",
 
8271
  "likelihood": "construct",
8272
  "path": "addons/l10n_id_efaktur_coretax/models/account_move.py"
8273
  },
8274
+ {
8275
+ "rule": "odoo.stored-compute-needs-complete-depends",
8276
+ "severity": "critical",
8277
+ "line": 30,
8278
+ "evidence": "_compute_name() reads 'invoice_ids.name' but @api.depends does not declare it -- the stored value goes stale when 'invoice_ids.name' changes",
8279
+ "suppressed": false,
8280
+ "advisory": false,
8281
+ "likelihood": "construct",
8282
+ "path": "addons/l10n_id_efaktur_coretax/models/efaktur_document.py"
8283
+ },
8284
  {
8285
  "rule": "odoo.ondelete-is-a-decision",
8286
  "severity": "high",
 
9251
  "likelihood": "construct",
9252
  "path": "addons/l10n_latam_check/models/l10n_latam_check.py"
9253
  },
9254
+ {
9255
+ "rule": "odoo.stored-compute-needs-complete-depends",
9256
+ "severity": "critical",
9257
+ "line": 146,
9258
+ "evidence": "_compute_highest_name() reads 'l10n_latam_manual_document_number' but @api.depends does not declare it -- the stored value goes stale when 'l10n_latam_manual_document_number' changes",
9259
+ "suppressed": false,
9260
+ "advisory": false,
9261
+ "likelihood": "construct",
9262
+ "path": "addons/l10n_latam_invoice_document/models/account_move.py"
9263
+ },
9264
  {
9265
  "rule": "odoo.stored-compute-needs-complete-depends",
9266
  "severity": "critical",
 
11301
  "likelihood": "unmeasured",
11302
  "path": "addons/mail/wizard/mail_activity_schedule.py"
11303
  },
11304
+ {
11305
+ "rule": "odoo.constrains-must-list-every-field-read",
11306
+ "severity": "critical",
11307
+ "line": 201,
11308
+ "evidence": "_check_consistency() reads 'error' but @api.constrains declares only activity_type_id, activity_user_id, plan_id, plan_on_demand_user_id, res_ids, res_model_id -- writing 'error' alone skips the check entirely",
11309
+ "suppressed": false,
11310
+ "advisory": false,
11311
+ "likelihood": "construct",
11312
+ "path": "addons/mail/wizard/mail_activity_schedule.py"
11313
+ },
11314
  {
11315
  "rule": "odoo.commit-inside-a-loop-splits-the-transaction",
11316
  "severity": "high",
 
13631
  "likelihood": "unmeasured",
13632
  "path": "addons/point_of_sale/models/pos_session.py"
13633
  },
13634
+ {
13635
+ "rule": "odoo.stored-compute-needs-complete-depends",
13636
+ "severity": "critical",
13637
+ "line": 291,
13638
+ "evidence": "_compute_cash_journal() reads 'payment_method_ids.is_cash_count' but @api.depends does not declare it -- the stored value goes stale when 'payment_method_ids.is_cash_count' changes",
13639
+ "suppressed": false,
13640
+ "advisory": false,
13641
+ "likelihood": "construct",
13642
+ "path": "addons/point_of_sale/models/pos_session.py"
13643
+ },
13644
+ {
13645
+ "rule": "odoo.stored-compute-needs-complete-depends",
13646
+ "severity": "critical",
13647
+ "line": 291,
13648
+ "evidence": "_compute_cash_journal() reads 'payment_method_ids.journal_id' but @api.depends does not declare it -- the stored value goes stale when 'payment_method_ids.journal_id' changes",
13649
+ "suppressed": false,
13650
+ "advisory": false,
13651
+ "likelihood": "construct",
13652
+ "path": "addons/point_of_sale/models/pos_session.py"
13653
+ },
13654
  {
13655
  "rule": "odoo.prefer-sql-constraint",
13656
  "severity": "high",
 
13721
  "likelihood": "unproven",
13722
  "path": "addons/point_of_sale/models/pos_session.py"
13723
  },
13724
+ {
13725
+ "rule": "odoo.stored-compute-needs-complete-depends",
13726
+ "severity": "critical",
13727
+ "line": 31,
13728
+ "evidence": "_compute_color() reads 'pos_categ_ids.color' but @api.depends does not declare it -- the stored value goes stale when 'pos_categ_ids.color' changes",
13729
+ "suppressed": false,
13730
+ "advisory": false,
13731
+ "likelihood": "construct",
13732
+ "path": "addons/point_of_sale/models/product.py"
13733
+ },
13734
  {
13735
  "rule": "odoo.sudo-bypasses-record-rules",
13736
  "severity": "critical",
 
17341
  "likelihood": "defect",
17342
  "path": "addons/sale_crm/models/sale_order.py"
17343
  },
17344
+ {
17345
+ "rule": "odoo.stored-compute-needs-complete-depends",
17346
+ "severity": "critical",
17347
+ "line": 12,
17348
+ "evidence": "_compute_purchase_price() reads 'expense_id' but @api.depends does not declare it -- the stored value goes stale when 'expense_id' changes",
17349
+ "suppressed": false,
17350
+ "advisory": false,
17351
+ "likelihood": "construct",
17352
+ "path": "addons/sale_expense_margin/models/sale_order_line.py"
17353
+ },
17354
  {
17355
  "rule": "odoo.ondelete-is-a-decision",
17356
  "severity": "high",
 
18561
  "likelihood": "construct",
18562
  "path": "addons/sale_timesheet/models/hr_timesheet.py"
18563
  },
18564
+ {
18565
+ "rule": "odoo.stored-compute-needs-complete-depends",
18566
+ "severity": "critical",
18567
+ "line": 163,
18568
+ "evidence": "_compute_partner_id() reads 'allow_billable' but @api.depends does not declare it -- the stored value goes stale when 'allow_billable' changes",
18569
+ "suppressed": false,
18570
+ "advisory": false,
18571
+ "likelihood": "construct",
18572
+ "path": "addons/sale_timesheet/models/project_project.py"
18573
+ },
18574
  {
18575
  "rule": "odoo.stored-compute-needs-complete-depends",
18576
  "severity": "critical",
 
20181
  "likelihood": "unmeasured",
20182
  "path": "addons/stock/models/stock_scrap.py"
20183
  },
20184
+ {
20185
+ "rule": "odoo.stored-compute-needs-complete-depends",
20186
+ "severity": "critical",
20187
+ "line": 100,
20188
+ "evidence": "_compute_scrap_qty() reads 'move_ids.quantity' but @api.depends does not declare it -- the stored value goes stale when 'move_ids.quantity' changes",
20189
+ "suppressed": false,
20190
+ "advisory": false,
20191
+ "likelihood": "construct",
20192
+ "path": "addons/stock/models/stock_scrap.py"
20193
+ },
20194
  {
20195
  "rule": "odoo.ondelete-is-a-decision",
20196
  "severity": "high",
 
22561
  "likelihood": "construct",
22562
  "path": "addons/website_sale_gelato/models/product_document.py"
22563
  },
22564
+ {
22565
+ "rule": "odoo.constrains-must-list-every-field-read",
22566
+ "severity": "critical",
22567
+ "line": 14,
22568
+ "evidence": "_check_print_images_are_set_before_publishing() reads 'gelato_template_ref' but @api.constrains declares only is_published -- writing 'gelato_template_ref' alone skips the check entirely",
22569
+ "suppressed": false,
22570
+ "advisory": false,
22571
+ "likelihood": "construct",
22572
+ "path": "addons/website_sale_gelato/models/product_template.py"
22573
+ },
22574
  {
22575
  "rule": "odoo.constrains-must-list-every-field-read",
22576
  "severity": "critical",
 
22861
  "likelihood": "construct",
22862
  "path": "addons/website_slides/models/slide_slide.py"
22863
  },
22864
+ {
22865
+ "rule": "odoo.stored-compute-needs-complete-depends",
22866
+ "severity": "critical",
22867
+ "line": 384,
22868
+ "evidence": "_compute_slides_statistics() reads 'is_category' but @api.depends does not declare it -- the stored value goes stale when 'is_category' changes",
22869
+ "suppressed": false,
22870
+ "advisory": false,
22871
+ "likelihood": "construct",
22872
+ "path": "addons/website_slides/models/slide_slide.py"
22873
+ },
22874
  {
22875
  "rule": "odoo.sudo-bypasses-record-rules",
22876
  "severity": "critical",
fieldtest-openspp-2026-09.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "generated_utc": "2026-09-27T16:05:31+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "OpenSPP2-19.0"
@@ -811,6 +811,16 @@
811
  "likelihood": "construct",
812
  "path": "spp_approval/models/approval_review_multitier.py"
813
  },
 
 
 
 
 
 
 
 
 
 
814
  {
815
  "rule": "odoo.sudo-bypasses-record-rules",
816
  "severity": "critical",
@@ -2971,6 +2981,56 @@
2971
  "likelihood": "unproven",
2972
  "path": "spp_programs/models/programs.py"
2973
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2974
  {
2975
  "rule": "odoo.sql-needs-query-parameters",
2976
  "severity": "critical",
 
1
  {
2
+ "generated_utc": "2026-09-27T16:45:07+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "OpenSPP2-19.0"
 
811
  "likelihood": "construct",
812
  "path": "spp_approval/models/approval_review_multitier.py"
813
  },
814
+ {
815
+ "rule": "odoo.stored-compute-needs-complete-depends",
816
+ "severity": "critical",
817
+ "line": 53,
818
+ "evidence": "_compute_current_tier() reads 'tier_review_ids.sequence' but @api.depends does not declare it -- the stored value goes stale when 'tier_review_ids.sequence' changes",
819
+ "suppressed": false,
820
+ "advisory": false,
821
+ "likelihood": "construct",
822
+ "path": "spp_approval/models/approval_review_multitier.py"
823
+ },
824
  {
825
  "rule": "odoo.sudo-bypasses-record-rules",
826
  "severity": "critical",
 
2981
  "likelihood": "unproven",
2982
  "path": "spp_programs/models/programs.py"
2983
  },
2984
+ {
2985
+ "rule": "odoo.stored-compute-needs-complete-depends",
2986
+ "severity": "critical",
2987
+ "line": 54,
2988
+ "evidence": "_compute_cycle_membership_count() reads 'is_registrant' but @api.depends does not declare it -- the stored value goes stale when 'is_registrant' changes",
2989
+ "suppressed": false,
2990
+ "advisory": false,
2991
+ "likelihood": "construct",
2992
+ "path": "spp_programs/models/registrant.py"
2993
+ },
2994
+ {
2995
+ "rule": "odoo.stored-compute-needs-complete-depends",
2996
+ "severity": "critical",
2997
+ "line": 96,
2998
+ "evidence": "_compute_program_membership_count() reads 'is_registrant' but @api.depends does not declare it -- the stored value goes stale when 'is_registrant' changes",
2999
+ "suppressed": false,
3000
+ "advisory": false,
3001
+ "likelihood": "construct",
3002
+ "path": "spp_programs/models/registrant.py"
3003
+ },
3004
+ {
3005
+ "rule": "odoo.stored-compute-needs-complete-depends",
3006
+ "severity": "critical",
3007
+ "line": 121,
3008
+ "evidence": "_compute_entitlements_count() reads 'is_registrant' but @api.depends does not declare it -- the stored value goes stale when 'is_registrant' changes",
3009
+ "suppressed": false,
3010
+ "advisory": false,
3011
+ "likelihood": "construct",
3012
+ "path": "spp_programs/models/registrant.py"
3013
+ },
3014
+ {
3015
+ "rule": "odoo.stored-compute-needs-complete-depends",
3016
+ "severity": "critical",
3017
+ "line": 146,
3018
+ "evidence": "_compute_cycle_count() reads 'is_registrant' but @api.depends does not declare it -- the stored value goes stale when 'is_registrant' changes",
3019
+ "suppressed": false,
3020
+ "advisory": false,
3021
+ "likelihood": "construct",
3022
+ "path": "spp_programs/models/registrant.py"
3023
+ },
3024
+ {
3025
+ "rule": "odoo.stored-compute-needs-complete-depends",
3026
+ "severity": "critical",
3027
+ "line": 171,
3028
+ "evidence": "_compute_inkind_entitlements_count() reads 'is_registrant' but @api.depends does not declare it -- the stored value goes stale when 'is_registrant' changes",
3029
+ "suppressed": false,
3030
+ "advisory": false,
3031
+ "likelihood": "construct",
3032
+ "path": "spp_programs/models/registrant.py"
3033
+ },
3034
  {
3035
  "rule": "odoo.sql-needs-query-parameters",
3036
  "severity": "critical",
tests/test_semantics.py CHANGED
@@ -432,6 +432,186 @@ def test_a_mapped_lambda_or_name_is_not_guessed():
432
  assert semantics.run_check("depends_covers_compute_reads", code) == [], call
433
 
434
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  # ------------------------------------------- narrowed by `nexa recall --aim`
436
 
437
  # Placed against the method each fix edited, prefer-sql-constraint scored 0
 
432
  assert semantics.run_check("depends_covers_compute_reads", code) == [], call
433
 
434
 
435
+ # mapped()'s siblings name what they read the same way, and were silent after
436
+ # it was not. Each call below reads the paths beside it, relative to
437
+ # invoice_ids; a trailing id names the relation and is never asked for.
438
+ NAMED_READS = [
439
+ ('filtered("is_paid")', ["is_paid"]),
440
+ ('filtered("partner_id.is_company")', ["partner_id.is_company"]),
441
+ ('grouped("journal_id")', ["journal_id"]),
442
+ ('sorted("sequence")', ["sequence"]),
443
+ ('sorted(key="sequence", reverse=True)', ["sequence"]),
444
+ ('sorted("date desc, id")', ["date"]),
445
+ ('sorted("partner_id.name DESC NULLS LAST, date:month")', ["partner_id.name", "date"]),
446
+ ('filtered_domain([("state", "=", "done"), "|", (1, "=", 1), '
447
+ '("partner_id.id", "=", 7)])', ["partner_id", "state"]),
448
+ ('filtered_domain([("line_ids", "any", [("paid", "=", True)])])',
449
+ ["line_ids", "line_ids.paid"]),
450
+ ]
451
+
452
+
453
+ @pytest.mark.parametrize("call, reads", NAMED_READS)
454
+ def test_a_field_named_in_a_string_is_a_read_the_depends_must_declare(call, reads):
455
+ code = MAPPED_COMPUTE.format(declared='"invoice_ids"', call=call)
456
+ hits = semantics.run_check("depends_covers_compute_reads", code)
457
+ reported = sorted(message.split("'")[1] for _, message, _ in hits)
458
+ assert reported == sorted("invoice_ids." + read for read in reads), hits
459
+
460
+
461
+ @pytest.mark.parametrize("call, reads", NAMED_READS)
462
+ def test_a_field_named_in_a_string_the_depends_declares_is_quiet(call, reads):
463
+ declared = ", ".join('"invoice_ids.{0}"'.format(read) for read in reads)
464
+ code = MAPPED_COMPUTE.format(declared=declared, call=call)
465
+ assert semantics.run_check("depends_covers_compute_reads", code) == []
466
+
467
+
468
+ @pytest.mark.parametrize("call", [
469
+ "filtered(lambda i: i.is_paid)",
470
+ "sorted(key=lambda i: i.sequence)",
471
+ "sorted(ORDER)",
472
+ "grouped(KEY)",
473
+ "filtered_domain(DOMAIN)",
474
+ "filtered_domain(DOMAIN + [LEAF])",
475
+ 'sorted("date desc limit 1")',
476
+ 'filtered("line_ids[0]")',
477
+ ])
478
+ def test_a_field_not_written_as_a_literal_is_not_guessed(call):
479
+ code = MAPPED_COMPUTE.format(declared='"invoice_ids"', call=call)
480
+ assert semantics.run_check("depends_covers_compute_reads", code) == [], call
481
+
482
+
483
+ NAMED_EXEMPT = """{head}
484
+ @api.depends("invoice_ids", "line_ids.partner_id.name")
485
+ def _compute_total(self):
486
+ for rec in self:
487
+ {body}
488
+ """
489
+
490
+
491
+ @pytest.mark.parametrize("head, body", [
492
+ # declared as a longer path: line_ids.partner_id.name covers line_ids.partner_id
493
+ ("class A(models.Model):", 'rec.total = rec.line_ids.filtered("partner_id")'),
494
+ # the field this method computes
495
+ ("class A(models.Model):", 'rec.total = rec.filtered("total") and 1'),
496
+ # a non-stored compute
497
+ ('class A(models.Model):\n total = fields.Float(compute="_compute_total")',
498
+ 'rec.total = len(rec.invoice_ids.filtered("is_paid"))'),
499
+ # a wizard
500
+ ("class A(models.TransientModel):",
501
+ 'rec.total = len(rec.invoice_ids.filtered("is_paid"))'),
502
+ # phrasing a message
503
+ ("class A(models.Model):",
504
+ 'raise UserError(rec.invoice_ids.filtered("is_paid").mapped("name"))'),
505
+ ("class A(models.Model):",
506
+ '_logger.info("%s", rec.invoice_ids.sorted("date")[:1].name)'),
507
+ ])
508
+ def test_a_field_named_in_a_string_keeps_the_checkers_exemptions(head, body):
509
+ code = NAMED_EXEMPT.format(head=head, body=body)
510
+ assert semantics.run_check("depends_covers_compute_reads", code) == [], body
511
+
512
+
513
+ def test_a_named_display_name_override_is_left_alone():
514
+ code = """@api.depends("invoice_ids")
515
+ def _compute_display_name(self):
516
+ for rec in self:
517
+ rec.display_name = ", ".join(rec.invoice_ids.sorted("date").mapped("name"))
518
+ """
519
+ assert semantics.run_check("depends_covers_compute_reads", code) == []
520
+
521
+
522
+ # A subscript stopped the path. rec.line_ids[0].amount read nothing past
523
+ # line_ids, so a compute taking its value from the first line looked declared.
524
+ SUBSCRIPT_COMPUTE = """@api.depends({declared})
525
+ def _compute_first(self):
526
+ for rec in self:
527
+ rec.first = {read}
528
+ """
529
+
530
+
531
+ @pytest.mark.parametrize("read, path", [
532
+ ("rec.line_ids[0].amount", "line_ids.amount"),
533
+ ("rec.line_ids[-1].amount", "line_ids.amount"),
534
+ ("rec.line_ids[1:3].amount", "line_ids.amount"),
535
+ ("rec.line_ids.filtered(lambda l: l.paid)[:1].amount", "line_ids.amount"),
536
+ ("self[0].line_ids.amount", "line_ids.amount"),
537
+ ('rec.line_ids[:1].mapped("amount")', "line_ids.amount"),
538
+ ])
539
+ def test_a_read_after_a_subscript_by_position_is_a_read(read, path):
540
+ code = SUBSCRIPT_COMPUTE.format(declared='"line_ids"', read=read)
541
+ hits = semantics.run_check("depends_covers_compute_reads", code)
542
+ assert ["'{0}'".format(path) in message for _, message, _ in hits] == [True], hits
543
+ code = SUBSCRIPT_COMPUTE.format(declared='"{0}"'.format(path), read=read)
544
+ assert semantics.run_check("depends_covers_compute_reads", code) == [], read
545
+
546
+
547
+ def test_a_subscript_reads_every_segment_of_the_chain_after_it():
548
+ """rec.partner_id.child_ids[:1].email reported partner_id.child_ids and
549
+ never the email, which is the field whose change goes unnoticed."""
550
+ code = SUBSCRIPT_COMPUTE.format(declared='"partner_id"',
551
+ read="rec.partner_id.child_ids[:1].email")
552
+ hits = semantics.run_check("depends_covers_compute_reads", code)
553
+ assert sorted(message.split("'")[1] for _, message, _ in hits) == [
554
+ "partner_id.child_ids", "partner_id.child_ids.email"], hits
555
+
556
+
557
+ def test_a_loop_over_a_slice_binds_the_same_path():
558
+ code = """@api.depends("line_ids")
559
+ def _compute_rest(self):
560
+ for rec in self:
561
+ for line in rec.line_ids.sorted("sequence")[1:]:
562
+ rec.rest += line.discount
563
+ """
564
+ hits = semantics.run_check("depends_covers_compute_reads", code)
565
+ assert sorted(message.split("'")[1] for _, message, _ in hits) == [
566
+ "line_ids.discount", "line_ids.sequence"], hits
567
+
568
+
569
+ @pytest.mark.parametrize("read", [
570
+ "rec[fname].name", # a field read by name, not records by position
571
+ "rec.line_ids[i].amount", # a name could hold either
572
+ 'rec["partner_id"].name',
573
+ "rec.line_ids[0].id", # the relation itself, which is declared
574
+ ])
575
+ def test_a_subscript_that_does_not_pick_records_by_position_is_not_followed(read):
576
+ code = SUBSCRIPT_COMPUTE.format(declared='"line_ids"', read=read)
577
+ assert semantics.run_check("depends_covers_compute_reads", code) == [], read
578
+
579
+
580
+ # A constraint reads a field through a string as a compute does, and writing
581
+ # that field alone skips the check. Only the root is compared, as for every
582
+ # other read here: @api.constrains cannot follow a dotted name.
583
+ NAMED_CONSTRAINT = """@api.constrains({declared})
584
+ def _check_dates(self):
585
+ {body}
586
+ """
587
+
588
+
589
+ @pytest.mark.parametrize("body, root", [
590
+ ('if any(self.mapped("date_end")):\n raise ValidationError("x")', "date_end"),
591
+ ('for rec in self.filtered("active"):\n rec._check()', "active"),
592
+ ('if self.sorted("sequence")[:1].date_start:\n raise ValidationError("x")',
593
+ "sequence"),
594
+ ('if self.filtered_domain([("state", "=", "done")]):\n raise ValidationError("x")',
595
+ "state"),
596
+ ('if self[0].code:\n raise ValidationError("x")', "code"),
597
+ ('for spread in self:\n moves = spread.mapped("line_ids.move_id")', "line_ids"),
598
+ ])
599
+ def test_a_constraint_reading_a_field_through_a_string_must_list_it(body, root):
600
+ code = NAMED_CONSTRAINT.format(declared='"date_start"', body=body)
601
+ hits = semantics.run_check("constrains_lists_every_field_read", code)
602
+ assert ["reads '{0}'".format(root) in message for _, message, _ in hits] == [True], hits
603
+ code = NAMED_CONSTRAINT.format(declared='"date_start", "{0}"'.format(root), body=body)
604
+ assert semantics.run_check("constrains_lists_every_field_read", code) == [], body
605
+
606
+
607
+ def test_a_constraint_naming_a_field_only_in_its_message_is_quiet():
608
+ code = NAMED_CONSTRAINT.format(
609
+ declared='"date_start"',
610
+ body='if self.date_start:\n'
611
+ ' raise ValidationError(", ".join(self.mapped("name")))')
612
+ assert semantics.run_check("constrains_lists_every_field_read", code) == []
613
+
614
+
615
  # ------------------------------------------- narrowed by `nexa recall --aim`
616
 
617
  # Placed against the method each fix edited, prefer-sql-constraint scored 0