depends: what a string names and what a subscript picks are reads
Browse filesmapped()'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 +151 -3
- README.md +16 -16
- bin/semantics.py +166 -32
- classification-oca-2026-09.json +20 -2
- classification-openspp-rest-2026-09.json +59 -2
- fieldtest-oca-2026-09.json +21 -1
- fieldtest-odoo-core-2026-09.json +201 -1
- fieldtest-openspp-2026-09.json +61 -1
- tests/test_semantics.py +180 -0
|
@@ -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-->
|
| 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-->
|
| 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-->
|
| 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
|
|
@@ -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-->
|
| 136 |
-
thousand lines. <!--fig:oca.read-->
|
| 137 |
-
**<!--fig:oca.read.true-->
|
| 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-->
|
| 147 |
-
**<!--fig:openspp.read.true-->
|
| 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-->
|
| 613 |
-
| Those findings, read by hand | **<!--fig:oca.read.true-->
|
| 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-->
|
| 615 |
-
| Those findings, read by hand | **<!--fig:openspp.read.true-->
|
| 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,
|
| 620 |
-
| Hand-classified true positives still reported | **<!--fig:survival.found-->
|
| 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-->
|
| 747 |
-
<!--fig:survival.found-->
|
| 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-->
|
| 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,
|
| 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,
|
| 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 |
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 204 |
-
|
| 205 |
-
|
|
|
|
| 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
|
| 329 |
-
"""Field paths
|
| 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 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 349 |
-
and not node.keywords) or id(node) in quiet:
|
| 350 |
continue
|
| 351 |
-
|
| 352 |
-
if not
|
| 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 |
-
|
| 361 |
-
|
| 362 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 598 |
-
(
|
| 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) +
|
| 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
|
|
|
|
| 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
|
|
@@ -13,7 +13,7 @@
|
|
| 13 |
},
|
| 14 |
"totals": {
|
| 15 |
"false": 11,
|
| 16 |
-
"true":
|
| 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 |
}
|
|
@@ -11,7 +11,7 @@
|
|
| 11 |
"files": 1351,
|
| 12 |
"lines": 226937,
|
| 13 |
"totals": {
|
| 14 |
-
"true":
|
| 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 |
}
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_utc": "2026-09-27T16:
|
| 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",
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_utc": "2026-09-27T16:
|
| 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",
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_utc": "2026-09-27T16:
|
| 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",
|
|
@@ -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
|