DBax127 commited on
Commit
77aa4e0
·
1 Parent(s): 6eb635c

onchange: backed by a constraint on its field, refused only when taken back

Browse files

Two changes to onchange-is-not-validation, shipped together because the
first reads true only with the second. A constraint backs an onchange
only when it covers a field the onchange watches (models.Constraint and
_sql_constraints by the names in their SQL, @api.constrains by its plain
names, unreadable ones cover everything); before, any constraint in the
file cleared it. And a returned warning dict counts as a refusal only
when its branch also writes a watched field or clears a field on the
record; a warning beside no write is a notice the user saves past in
the form as in an import. A raise still counts on its own.

OCA 99 unchanged (account_loan's rate note stays out). OpenSPP 153 ->
157, all read into classification-openspp-rest: spp_area level cap,
CR type required documents, one head per group true; spp_import_match
false, backed by the parent's constraint. Core 2345 -> 2339: +11 (8
true: four partner blocks, loyalty expiry, negative price rounding,
expense edit rights, a component equal to its product; 3 checked on the
server elsewhere), -17, all read, all notices (OCR checksum, GST name
length, token archiving, open cart pricelist, ...). A first draft that
counted only watched-field writes lost spp_change_request_v2's
registrant clear, which nothing else enforces; clearing any field
counts now. 280 of 280 hand-classified true positives still reported.
ARCHITECTURE 3.68; 3.67's worklist line updated.

ARCHITECTURE.md CHANGED
@@ -3354,8 +3354,8 @@ warning dict as a refusal. Narrowing the guard first would ship that noise, 25 f
3354
  true, so it was not taken. A variant that counts a warning dict only when the onchange also
3355
  resets a field it watches was run for information. It keeps all 11 true additions and 4 of the
3356
  false ones, but it also removes 17 existing core findings and 1 on OpenSPP that nobody has read.
3357
- Whether that is the right next change depends on reading those 18 first. It is on the worklist,
3358
- not in the checker.
3359
 
3360
  **The series half ships.** A `_sql_constraints` block no longer counts as backing on a tree
3361
  that pins 19 or later; on 16 to 18 and on an unknown series it still does, as §3.45 requires.
@@ -3366,6 +3366,65 @@ includes the state before OpenSPP's own incident fix (208d9758), whose file also
3366
  registry throws away is not a backstop, and the checker should not say it is. All 277
3367
  hand-classified true positives are still reported.
3368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3369
  ## 4. Performance traps, all found by measurement
3370
 
3371
  Within four instrumented runs the report showed the 7B at ~10 tok/s against a
 
3354
  true, so it was not taken. A variant that counts a warning dict only when the onchange also
3355
  resets a field it watches was run for information. It keeps all 11 true additions and 4 of the
3356
  false ones, but it also removes 17 existing core findings and 1 on OpenSPP that nobody has read.
3357
+ Whether that is the right next change depended on reading those 18 first. They were read the
3358
+ same day, and §3.68 is what came of it.
3359
 
3360
  **The series half ships.** A `_sql_constraints` block no longer counts as backing on a tree
3361
  that pins 19 or later; on 16 to 18 and on an unknown series it still does, as §3.45 requires.
 
3366
  registry throws away is not a backstop, and the checker should not say it is. All 277
3367
  hand-classified true positives are still reported.
3368
 
3369
+ ### 3.68 A warning refuses only when it takes the value back
3370
+
3371
+ **Measured 2026-09-27.** §3.67 left the field-matched guard out because the file-wide one was
3372
+ hiding a wider test: any returned warning dict counted as a refusal. Both changes ship now,
3373
+ together, and the second is what makes the first read true.
3374
+
3375
+ `_refuses_in_branch` asks one thing of a warning: does the branch that returns it also take the
3376
+ value back? The branch is the innermost `if`, `for` or `while` around the warning, or the whole
3377
+ body when there is none. Taking the value back is writing a field the onchange watches (the
3378
+ quantity clamped to zero, the removed document restored, the component taken out again), or
3379
+ clearing any field on the record to `False`, `None` or empty (the blocked partner, the
3380
+ registrant that no longer fits). A `raise` still counts on its own. A warning with no write
3381
+ beside it is a notice, and the user saves past it in the form as easily as in an import, so
3382
+ nothing is being enforced anywhere and the rule's harm is not there. A write outside the
3383
+ warning's branch does not count either: `account.move` sets its `show_name_warning` flag in
3384
+ an earlier `if`, and that flag is not a refusal.
3385
+
3386
+ The first draft only looked for writes to watched fields, and reading what it removed stopped
3387
+ it. `spp_change_request_v2`'s `_onchange_request_type_id` watches the request type and clears
3388
+ the registrant when a group lands on an individual-only type. Nothing else checks that pairing:
3389
+ the registrant's domain is a form attribute, so a change request made through the API can pair
3390
+ them. Clearing a field the onchange does not watch is a refusal too, and the test now says so.
3391
+ Setting one to a suggested value is not: a scrap order's recommended location, moved in for
3392
+ the user with a note, refuses nothing.
3393
+
3394
+ | | before | after |
3395
+ |---|---|---|
3396
+ | OCA | 99 | 99 |
3397
+ | OpenSPP | 153 | **157** |
3398
+ | odoo core | 2,345 | **2,339** |
3399
+
3400
+ Every change was read. Added: on OpenSPP the four from §3.67, 3 true and 1 false, now in
3401
+ `classification-openspp-rest-2026-09.json` under a new class, `backed-by-the-parent-constraint`.
3402
+ On core, 11: the 8 true from §3.67 (the four partner blocks, loyalty expiry, negative price
3403
+ rounding, expense edit rights, a component equal to its product) and 3 of the 4 that are checked
3404
+ on the server elsewhere (the work order's dates, the reference unit, the Argentinian journal).
3405
+ The API key's duration no longer appears: its onchange only warns. OCA's `account_loan` note
3406
+ does not come back.
3407
+
3408
+ Removed: 17 on core, all read, and none refuses anything. Six are advice about a value that
3409
+ saves anyway: an OCR number that fails its checksum, an invoice name too long for GST rule
3410
+ 46(b), an EGP rate with more than five decimals, a rounding finer than the decimal accuracy, a
3411
+ package heavier than its type allows, a pricelist minimum quantity that event tickets will
3412
+ ignore. The other eleven say what is about to happen: tokens archived with a payment method or
3413
+ provider, a payment method attached to a provider, pricelists archived with the feature, a
3414
+ carrier's rating error, a subcontracting purchase order, a decreased service quantity, tracked
3415
+ products already in stock, a storage category with no location, an open cart keeping its
3416
+ pricelist, and a scrap order's recommended location. None of them is in a classification file.
3417
+
3418
+ So the rule now reports 15 more onchanges than it did and 17 fewer, and of the 15, 11 are
3419
+ true. OpenSPP's census gains 3 true and 1 false; core's 17 removals were notices nobody had
3420
+ read, and they are not claimed as false positives fixed. All 280 hand-classified true
3421
+ positives are still reported, the 3 new ones among them.
3422
+
3423
+ What it still does not see: a constraint whose fields sit under another name, as
3424
+ `spp_import_match`'s parent `@api.constrains("field_ids")` does for its child's `field_id`, and a
3425
+ check the server makes in an inverse, a wizard's `create()` or at posting, none of which this
3426
+ checker reads as a constraint. The 3 false additions on core are that.
3427
+
3428
  ## 4. Performance traps, all found by measurement
3429
 
3430
  Within four instrumented runs the report showed the 7B at ~10 tok/s against a
README.md CHANGED
@@ -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-->153<!--/fig--> it makes today,
147
- **<!--fig:openspp.read.true-->131<!--/fig--> true, <!--fig:openspp.read.false-->16<!--/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
@@ -611,13 +611,13 @@ Same principle as `z.infer<typeof Schema>`: one definition, derived artifacts, n
611
  | Retrievable by their own probe | 27 of 27 at top 6, 25 of them at top 3 |
612
  | Hit rate on <!--fig:oca.lines-->33,960<!--/fig--> lines of reviewed OCA code | <!--fig:oca.rate-->2.9<!--/fig--> per 1000 lines: <!--fig:oca.findings-->99<!--/fig--> findings |
613
  | Those findings, read by hand | **<!--fig:oca.read.true-->83<!--/fig--> true positives**, <!--fig:oca.read.false-->6<!--/fig--> false, <!--fig:oca.read.undecidable-->7<!--/fig--> undecidable from one file, <!--fig:oca.unread-->3<!--/fig--> not yet read |
614
- | Hit rate on <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP (Odoo 19) | <!--fig:openspp.rate-->0.7<!--/fig--> per 1000 lines: <!--fig:openspp.findings-->153<!--/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-->131<!--/fig--> true positives**, <!--fig:openspp.read.false-->16<!--/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,345<!--/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-->277<!--/fig--> of <!--fig:survival.true-->277<!--/fig-->**, across <!--fig:survival.artifacts-->9<!--/fig--> classifications |
621
  | **Recall against real Odoo bugfixes** | **<!--fig:recall.all.caught-->20<!--/fig--> of <!--fig:recall.all.in_scope-->334<!--/fig-->, <!--fig:recall.all.pct-->6<!--/fig-->%, across three ecosystems. See below.** |
622
  | Verdict stability, 12 questions x 5 runs | 12/12 same verdict every run |
623
  | Escalation rescued a bad draft | 11 of 21, 95% CI 32–72% |
@@ -743,8 +743,8 @@ files on a `float_compare` written somewhere else in them.
743
  The check that makes all of this safe is the same one every time, and it is worth
744
  stating as a number rather than a promise: across all
745
  <!--fig:survival.artifacts-->9<!--/fig--> classification artifacts,
746
- **<!--fig:survival.true-->277<!--/fig--> hand-classified true positives,
747
- <!--fig:survival.found-->277<!--/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
@@ -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,345<!--/fig--> findings at
1091
  <!--fig:core.rate-->4.2<!--/fig--> per 1000 lines**.
1092
 
1093
  One finding is worth acting on outside this project. Core's
@@ -1101,7 +1101,7 @@ Seven rules are now sampled on core, <!--fig:cls.odoo-core.rows-->105<!--/fig-->
1101
  read, and the <!--fig:cls.sudo-core.rows-->45<!--/fig--> `sudo` findings a stricter
1102
  re-scoping test added were read in full: <!--fig:cls.sudo-core.true-->1<!--/fig--> true,
1103
  <!--fig:cls.sudo-core.false-->44<!--/fig--> false. Core makes
1104
- <!--fig:core.findings-->2,345<!--/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
 
 
143
 
144
  On <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP the rate is
145
  <!--fig:openspp.rate-->0.7<!--/fig-->, and those findings have been read too: of the
146
+ <!--fig:openspp.findings-->157<!--/fig--> it makes today,
147
+ **<!--fig:openspp.read.true-->134<!--/fig--> true, <!--fig:openspp.read.false-->17<!--/fig-->
148
  false, <!--fig:openspp.read.undecidable-->3<!--/fig--> undecidable**, with
149
  <!--fig:openspp.unread-->3<!--/fig--> not yet read. One rule produced 97 false positives
150
  along the way and has been rewritten because of them — that story is
 
611
  | Retrievable by their own probe | 27 of 27 at top 6, 25 of them at top 3 |
612
  | Hit rate on <!--fig:oca.lines-->33,960<!--/fig--> lines of reviewed OCA code | <!--fig:oca.rate-->2.9<!--/fig--> per 1000 lines: <!--fig:oca.findings-->99<!--/fig--> findings |
613
  | Those findings, read by hand | **<!--fig:oca.read.true-->83<!--/fig--> true positives**, <!--fig:oca.read.false-->6<!--/fig--> false, <!--fig:oca.read.undecidable-->7<!--/fig--> undecidable from one file, <!--fig:oca.unread-->3<!--/fig--> not yet read |
614
+ | Hit rate on <!--fig:openspp.lines-->226,937<!--/fig--> lines of OpenSPP (Odoo 19) | <!--fig:openspp.rate-->0.7<!--/fig--> per 1000 lines: <!--fig:openspp.findings-->157<!--/fig--> findings, +<!--fig:openspp.decided-->127<!--/fig--> another tool had already flagged, +<!--fig:openspp.advisory-->82<!--/fig--> advisory |
615
+ | Those findings, read by hand | **<!--fig:openspp.read.true-->134<!--/fig--> true positives**, <!--fig:openspp.read.false-->17<!--/fig--> false, <!--fig:openspp.read.undecidable-->3<!--/fig--> undecidable, <!--fig:openspp.unread-->3<!--/fig--> not yet read |
616
  | …the two `depends`/`constrains` rules alone | **<!--fig:cls.openspp.true-->87<!--/fig--> true**, <!--fig:cls.openspp.false-->15<!--/fig--> false, <!--fig:cls.openspp.undecidable-->3<!--/fig--> undecidable: <!--fig:cls.openspp.precision-->85<!--/fig-->% |
617
  | …the 8 composed-in Python rules alone | **<!--fig:cls.python.true-->9<!--/fig--> true**, <!--fig:cls.python.false-->42<!--/fig--> false: <!--fig:cls.python.precision-->18<!--/fig-->%, after 18 datetime verdicts were reversed: Odoo runs in UTC |
618
  | …`sudo-bypasses-record-rules` alone, before it was narrowed | <!--fig:cls.openspp-rest.sudo-bypasses-record-rules.true-->6<!--/fig--> true, <!--fig:cls.openspp-rest.sudo-bypasses-record-rules.false-->97<!--/fig--> false — 5.8%, and the reason it is now an AST pass |
619
+ | Hit rate on <!--fig:core.lines-->559,663<!--/fig--> lines of odoo core 18.0 | <!--fig:core.rate-->4.2<!--/fig--> per 1000 lines: <!--fig:core.findings-->2,339<!--/fig--> findings, <!--fig:core.read-->89<!--/fig--> of them read by hand: <!--fig:core.read.true-->45<!--/fig--> true, <!--fig:core.read.false-->44<!--/fig--> false |
620
+ | Hand-classified true positives still reported | **<!--fig:survival.found-->280<!--/fig--> of <!--fig:survival.true-->280<!--/fig-->**, across <!--fig:survival.artifacts-->9<!--/fig--> classifications |
621
  | **Recall against real Odoo bugfixes** | **<!--fig:recall.all.caught-->20<!--/fig--> of <!--fig:recall.all.in_scope-->334<!--/fig-->, <!--fig:recall.all.pct-->6<!--/fig-->%, across three ecosystems. See below.** |
622
  | Verdict stability, 12 questions x 5 runs | 12/12 same verdict every run |
623
  | Escalation rescued a bad draft | 11 of 21, 95% CI 32–72% |
 
743
  The check that makes all of this safe is the same one every time, and it is worth
744
  stating as a number rather than a promise: across all
745
  <!--fig:survival.artifacts-->9<!--/fig--> classification artifacts,
746
+ **<!--fig:survival.true-->280<!--/fig--> hand-classified true positives,
747
+ <!--fig:survival.found-->280<!--/fig--> still found, none lost.** That is asserted by
748
  `python bin/figures.py --check`, which matches every `true` verdict to a finding in the
749
  committed fieldtest artifact for the same checkout at the same commit, and runs in CI.
750
  It is not done by reading the diff and hoping, and no longer by re-running against a
 
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,339<!--/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,339<!--/fig--> live ones. The rest stay unread,
1105
  and a rate on core is a statement about how often the rules fire there, not how often
1106
  they are right.
1107
 
bin/semantics.py CHANGED
@@ -3054,11 +3054,130 @@ def float_coercion_of_external_money(code):
3054
  return findings
3055
 
3056
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3057
  def onchange_tries_to_enforce(code, facts=None):
3058
  """odoo.onchange-is-not-validation
3059
 
3060
- An @api.onchange method that RAISES or returns a warning -- it is trying
3061
- to enforce an invariant the web client is the only thing that will apply.
 
3062
 
3063
  The regex this replaces read 400 characters after the decorator and could
3064
  not see where the method ended. All three of its false positives are the
@@ -3071,40 +3190,39 @@ def onchange_tries_to_enforce(code, facts=None):
3071
  positives -- an onchange that really does raise ValidationError, and ones
3072
  that return the warning dict -- all stand.
3073
 
3074
- A constraint anywhere in the file clears it, exactly as the
3075
- satisfied_pattern did: an onchange sitting beside a real @api.constrains
3076
- is the faster feedback the rule explicitly allows. _sql_constraints counts
3077
- here on 16 through 18 and on an unknown series, because it IS a constraint
 
 
3078
  there, and not when the tree pins 19 or later, where the registry never
3079
- creates it. That changed nothing on the three trees measured.
3080
-
3081
- "Anywhere in the file" is wider than the rule, and narrowing it to a
3082
- constraint that covers a field the onchange watches was measured on
3083
- 2026-09-27 and not taken. It added 36 findings, and 11 read true. Most of
3084
- the rest were never refusals: a warning dict that informs ("the rate is
3085
- far from the previous one", "this reference already exists") and resets
3086
- nothing. The file-wide guard had been hiding that the warning test is too
3087
- wide, and narrowing the guard first would ship the noise. See ARCHITECTURE
3088
- 3.67.
3089
  """
3090
  tree, _ = parse_python(code)
3091
  if tree is None:
3092
  return []
3093
- legacy_dead = _series_at_least((facts or {}).get("odoo_series"), 19.0)
3094
-
3095
- backed = False
3096
- for node in ast.walk(tree):
3097
- if isinstance(node, ast.Name) and node.id == "_sql_constraints":
3098
- backed = backed or not legacy_dead
3099
- elif isinstance(node, ast.Attribute) and node.attr in ("constrains",
3100
- "Constraint"):
3101
- backed = True
3102
- if backed:
3103
- return []
3104
 
3105
  findings = []
3106
  for fn in _functions(tree):
3107
- if _decorator_args(fn, "api", "onchange") is None:
 
 
 
 
 
 
3108
  continue
3109
  # Anchored on the decorator, not on the def below it. That is where
3110
  # the rule is, where a reader looking the finding up will look, and
@@ -3124,10 +3242,10 @@ def onchange_tries_to_enforce(code, facts=None):
3124
  else _final_name(exc)
3125
  if name and ("Error" in name or "Warning" in name):
3126
  spot = node.lineno
3127
- elif isinstance(node, ast.Dict):
3128
- for key in node.keys:
3129
- if isinstance(key, ast.Constant) and key.value == "warning":
3130
- spot = node.lineno
3131
  if spot is None:
3132
  continue
3133
  findings.append((
 
3054
  return findings
3055
 
3056
 
3057
+ class _Every(object):
3058
+ """A constraint is present and which fields it covers cannot be read."""
3059
+
3060
+
3061
+ EVERY_FIELD = _Every()
3062
+
3063
+
3064
+ def _sql_identifiers(node):
3065
+ """Names inside a constraint's SQL text, or EVERY_FIELD when the text is
3066
+ not a literal. Keywords come along (CHECK, UNIQUE, NOT) and match no field."""
3067
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
3068
+ return set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", node.value))
3069
+ return EVERY_FIELD
3070
+
3071
+
3072
+ def _constrained_fields(tree, legacy_dead):
3073
+ """Field names some constraint in this file covers, or EVERY_FIELD.
3074
+
3075
+ Three spellings: @api.constrains (its plain names -- a dotted one registers
3076
+ nothing), models.Constraint (the names in its SQL) and _sql_constraints (the
3077
+ names in each entry's SQL, and nothing at all when `legacy_dead`, because
3078
+ from 19 the registry never creates it). A constraint that is present but
3079
+ cannot be read -- @api.constrains(*FIELDS), a _sql_constraints inherited and
3080
+ extended -- covers every field, which is the reading this had before it
3081
+ looked at fields at all.
3082
+ """
3083
+ covered = set()
3084
+
3085
+ def add(names):
3086
+ if names is EVERY_FIELD:
3087
+ return True
3088
+ covered.update(names)
3089
+ return False
3090
+
3091
+ for fn in _functions(tree):
3092
+ declared = _decorator_args(fn, "api", "constrains")
3093
+ if declared is UNREADABLE:
3094
+ return EVERY_FIELD
3095
+ if declared:
3096
+ covered.update(d for d in declared if "." not in d)
3097
+ for node in ast.walk(tree):
3098
+ if isinstance(node, ast.Call):
3099
+ func = node.func
3100
+ name = func.attr if isinstance(func, ast.Attribute) else \
3101
+ func.id if isinstance(func, ast.Name) else None
3102
+ if name == "Constraint":
3103
+ if add(_sql_identifiers(node.args[0]) if node.args else EVERY_FIELD):
3104
+ return EVERY_FIELD
3105
+ continue
3106
+ if isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
3107
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
3108
+ if not any(isinstance(t, ast.Name) and t.id == "_sql_constraints"
3109
+ for t in targets) or node.value is None or legacy_dead:
3110
+ continue
3111
+ if not isinstance(node.value, (ast.List, ast.Tuple)):
3112
+ return EVERY_FIELD
3113
+ for entry in node.value.elts:
3114
+ if not (isinstance(entry, (ast.List, ast.Tuple)) and len(entry.elts) > 1):
3115
+ return EVERY_FIELD
3116
+ if add(_sql_identifiers(entry.elts[1])):
3117
+ return EVERY_FIELD
3118
+ return covered
3119
+
3120
+
3121
+ def _clears(value):
3122
+ """False, None, '' or an empty collection: what a form resets a field to."""
3123
+ if isinstance(value, ast.Constant):
3124
+ return value.value is False or value.value is None or value.value == ""
3125
+ if isinstance(value, ast.Dict):
3126
+ return not value.keys
3127
+ return isinstance(value, (ast.List, ast.Tuple, ast.Set)) and not value.elts
3128
+
3129
+
3130
+ def _refuses_in_branch(fn, warning, watched):
3131
+ """Does the branch that returns this warning also take the value back?
3132
+
3133
+ A warning dict on its own informs: "this reference already exists", "the
3134
+ rate is far from the previous one", "please choose another package type".
3135
+ The user saves anyway, from the form as from an import, so nothing is
3136
+ enforced anywhere and the rule's harm -- a check only the form applies --
3137
+ is not there. What refuses is the same branch writing back a field the
3138
+ onchange watches (the quantity clamped, the partner cleared, the removed
3139
+ document restored) or clearing any field on the record (a registrant that
3140
+ does not fit the new request type). "The same branch" is the innermost
3141
+ if, for or while around the warning, or the whole body when there is none.
3142
+ """
3143
+ parents = {}
3144
+ for node in ast.walk(fn):
3145
+ for child in ast.iter_child_nodes(node):
3146
+ parents[id(child)] = node
3147
+ branch = warning
3148
+ while branch is not fn and not isinstance(branch, (ast.If, ast.For, ast.AsyncFor,
3149
+ ast.While)):
3150
+ branch = parents.get(id(branch), fn)
3151
+ roots = set() if watched is UNREADABLE else {w.split(".")[0] for w in watched}
3152
+ aliases = _aliases(fn)
3153
+
3154
+ def on_record(node):
3155
+ return isinstance(node, ast.Name) and aliases.get(node.id) == ()
3156
+
3157
+ for node in ast.walk(branch):
3158
+ if isinstance(node, (ast.Assign, ast.AugAssign)):
3159
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
3160
+ for target in targets:
3161
+ if isinstance(target, ast.Attribute) and on_record(target.value) and (
3162
+ watched is UNREADABLE or target.attr in roots
3163
+ or _clears(node.value)):
3164
+ return True
3165
+ elif (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
3166
+ and node.func.attr == "update" and on_record(node.func.value)
3167
+ and node.args and isinstance(node.args[0], ast.Dict)):
3168
+ for key, value in zip(node.args[0].keys, node.args[0].values):
3169
+ if isinstance(key, ast.Constant) and (
3170
+ watched is UNREADABLE or key.value in roots or _clears(value)):
3171
+ return True
3172
+ return False
3173
+
3174
+
3175
  def onchange_tries_to_enforce(code, facts=None):
3176
  """odoo.onchange-is-not-validation
3177
 
3178
+ An @api.onchange method that RAISES, or returns a warning while taking the
3179
+ value back -- it is trying to enforce an invariant the web client is the
3180
+ only thing that will apply.
3181
 
3182
  The regex this replaces read 400 characters after the decorator and could
3183
  not see where the method ended. All three of its false positives are the
 
3190
  positives -- an onchange that really does raise ValidationError, and ones
3191
  that return the warning dict -- all stand.
3192
 
3193
+ A constraint in the file clears it when it covers a field the onchange
3194
+ watches: an onchange beside a real @api.constrains on the same field is
3195
+ the faster feedback the rule explicitly allows. Until 2026-09-27 ANY
3196
+ constraint anywhere in the file cleared it, so a UNIQUE(reference)
3197
+ silenced an onchange that raised on credit_limit. _sql_constraints counts
3198
+ on 16 through 18 and on an unknown series, because it IS a constraint
3199
  there, and not when the tree pins 19 or later, where the registry never
3200
+ creates it (_constrained_fields).
3201
+
3202
+ Narrowing the guard alone added 36 findings and 11 read true, because the
3203
+ wide guard had been hiding a second problem: a returned warning dict was
3204
+ read as a refusal whatever it said. Most were notices -- "this reference
3205
+ already exists", "the rate is far from the previous one" -- that the user
3206
+ saves past in the form as easily as in an import, so nothing is enforced
3207
+ anywhere. A warning counts now only when its branch also takes the value
3208
+ back (_refuses_in_branch). Both together: OCA unchanged, OpenSPP +4 (3
3209
+ true), core +11 (8 true) and -17, all 17 notices. ARCHITECTURE 3.67, 3.68.
3210
  """
3211
  tree, _ = parse_python(code)
3212
  if tree is None:
3213
  return []
3214
+ covered = _constrained_fields(
3215
+ tree, _series_at_least((facts or {}).get("odoo_series"), 19.0))
 
 
 
 
 
 
 
 
 
3216
 
3217
  findings = []
3218
  for fn in _functions(tree):
3219
+ watched = _decorator_args(fn, "api", "onchange")
3220
+ if watched is None:
3221
+ continue
3222
+ if covered is EVERY_FIELD or (watched is UNREADABLE and covered):
3223
+ continue
3224
+ if watched is not UNREADABLE and any(w.split(".")[0] in covered
3225
+ for w in watched):
3226
  continue
3227
  # Anchored on the decorator, not on the def below it. That is where
3228
  # the rule is, where a reader looking the finding up will look, and
 
3242
  else _final_name(exc)
3243
  if name and ("Error" in name or "Warning" in name):
3244
  spot = node.lineno
3245
+ elif isinstance(node, ast.Dict) and any(
3246
+ isinstance(key, ast.Constant) and key.value == "warning"
3247
+ for key in node.keys) and _refuses_in_branch(fn, node, watched):
3248
+ spot = node.lineno
3249
  if spot is None:
3250
  continue
3251
  findings.append((
classification-openspp-rest-2026-09.json CHANGED
@@ -11,8 +11,8 @@
11
  "files": 1351,
12
  "lines": 226937,
13
  "totals": {
14
- "true": 46,
15
- "false": 114,
16
  "undecidable": 3
17
  },
18
  "by_rule": {
@@ -33,8 +33,8 @@
33
  "false": 1
34
  },
35
  "odoo.onchange-is-not-validation": {
36
- "false": 3,
37
- "true": 4
38
  },
39
  "odoo.float-comparison-needs-float-utils": {
40
  "true": 2,
@@ -60,7 +60,8 @@
60
  "identifier-not-value": 2,
61
  "sql-view-model": 1,
62
  "deliberate-recompute-trigger": 1,
63
- "parent-store-refuses-the-cycle": 5
 
64
  },
65
  "hits": [
66
  {
@@ -1640,8 +1641,44 @@
1640
  "class": "no-record-rule-on-this-model",
1641
  "why": "no ir.rule is declared for this model anywhere in the tree, so sudo bypasses nothing",
1642
  "evidence": null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1643
  }
1644
  ],
1645
- "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.",
1646
  "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."
1647
- }
 
11
  "files": 1351,
12
  "lines": 226937,
13
  "totals": {
14
+ "true": 49,
15
+ "false": 115,
16
  "undecidable": 3
17
  },
18
  "by_rule": {
 
33
  "false": 1
34
  },
35
  "odoo.onchange-is-not-validation": {
36
+ "false": 4,
37
+ "true": 7
38
  },
39
  "odoo.float-comparison-needs-float-utils": {
40
  "true": 2,
 
60
  "identifier-not-value": 2,
61
  "sql-view-model": 1,
62
  "deliberate-recompute-trigger": 1,
63
+ "parent-store-refuses-the-cycle": 5,
64
+ "backed-by-the-parent-constraint": 1
65
  },
66
  "hits": [
67
  {
 
1641
  "class": "no-record-rule-on-this-model",
1642
  "why": "no ir.rule is declared for this model anywhere in the tree, so sudo bypasses nothing",
1643
  "evidence": null
1644
+ },
1645
+ {
1646
+ "rule": "odoo.onchange-is-not-validation",
1647
+ "path": "spp_area/models/area_core.py",
1648
+ "line": 88,
1649
+ "evidence": "@api.onchange(\"parent_id\")",
1650
+ "verdict": "true",
1651
+ "class": null,
1652
+ "why": "caps an area at level 10 here and nowhere else; the file's only constraints are unique(code), so an import or RPC can store level 11"
1653
+ },
1654
+ {
1655
+ "rule": "odoo.onchange-is-not-validation",
1656
+ "path": "spp_change_request_v2/models/change_request_type.py",
1657
+ "line": 206,
1658
+ "evidence": "@api.onchange(\"available_document_ids\")",
1659
+ "verdict": "true",
1660
+ "class": null,
1661
+ "why": "keeps required documents inside the available list only in the form; the file's constraints cover code, detail_model and the apply strategy"
1662
+ },
1663
+ {
1664
+ "rule": "odoo.onchange-is-not-validation",
1665
+ "path": "spp_import_match/models/import_match.py",
1666
+ "line": 187,
1667
+ "evidence": "@api.onchange(\"field_id\")",
1668
+ "verdict": "false",
1669
+ "class": "backed-by-the-parent-constraint",
1670
+ "why": "the parent spp.import.match's @api.constrains('field_ids') enforces the same no-duplicate rule, under a field name the child's onchange does not watch"
1671
+ },
1672
+ {
1673
+ "rule": "odoo.onchange-is-not-validation",
1674
+ "path": "spp_registry/models/group_membership.py",
1675
+ "line": 71,
1676
+ "evidence": "@api.onchange(\"membership_type_ids\")",
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
+ }
corpus/build_corpus.py CHANGED
@@ -674,7 +674,9 @@ ENRICH = {
674
  },
675
  "odoo.onchange-is-not-validation": {
676
  # An onchange that TRIES to enforce something: it raises, or it returns
677
- # the warning dict that is the other way to refuse a value in the form.
 
 
678
  # Bare @api.onchange is not a violation -- setting a default, filling a
679
  # field from a provider and narrowing a domain are what onchange is for,
680
  # and flagging those fired on all 16 onchange declarations in two OCA
@@ -696,7 +698,8 @@ ENRICH = {
696
  # and backs nothing the onchange watches; the finding was false because
697
  # the onchange's warning informs and refuses nothing.) The checker reads
698
  # odoo_series off the tree facts and stops counting _sql_constraints
699
- # when it says 19+, where the registry never creates it.
 
700
  "semantic_check": "onchange_tries_to_enforce",
701
  "source": ODOO_ONCHANGE,
702
  },
 
674
  },
675
  "odoo.onchange-is-not-validation": {
676
  # An onchange that TRIES to enforce something: it raises, or it returns
677
+ # the warning dict while taking the value back in the same branch,
678
+ # which is the other way to refuse a value in the form. A warning that
679
+ # only informs is saved past in the form too (ARCHITECTURE 3.68).
680
  # Bare @api.onchange is not a violation -- setting a default, filling a
681
  # field from a provider and narrowing a domain are what onchange is for,
682
  # and flagging those fired on all 16 onchange declarations in two OCA
 
698
  # and backs nothing the onchange watches; the finding was false because
699
  # the onchange's warning informs and refuses nothing.) The checker reads
700
  # odoo_series off the tree facts and stops counting _sql_constraints
701
+ # when it says 19+, where the registry never creates it, and counts a
702
+ # constraint only when it covers a field the onchange watches.
703
  "semantic_check": "onchange_tries_to_enforce",
704
  "source": ODOO_ONCHANGE,
705
  },
fieldtest-oca-2026-09.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "generated_utc": "2026-09-27T14:45:39+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "server-tools",
 
1
  {
2
+ "generated_utc": "2026-09-27T15:03:24+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "server-tools",
fieldtest-odoo-core-2026-09.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "generated_utc": "2026-09-27T14:50:55+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "odoo"
@@ -631,6 +631,16 @@
631
  "likelihood": "unmeasured",
632
  "path": "addons/account/models/account_move.py"
633
  },
 
 
 
 
 
 
 
 
 
 
634
  {
635
  "rule": "odoo.constrains-must-list-every-field-read",
636
  "severity": "critical",
@@ -3741,16 +3751,6 @@
3741
  "likelihood": "unproven",
3742
  "path": "addons/delivery/models/sale_order.py"
3743
  },
3744
- {
3745
- "rule": "odoo.onchange-is-not-validation",
3746
- "severity": "critical",
3747
- "line": 43,
3748
- "evidence": "@api.onchange '_onchange_order_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
3749
- "suppressed": false,
3750
- "advisory": false,
3751
- "likelihood": "unmeasured",
3752
- "path": "addons/delivery/wizard/choose_delivery_carrier.py"
3753
- },
3754
  {
3755
  "rule": "odoo.constrains-iterate-recordset",
3756
  "severity": "high",
@@ -5211,6 +5211,16 @@
5211
  "likelihood": "construct",
5212
  "path": "addons/hr_expense/models/hr_expense.py"
5213
  },
 
 
 
 
 
 
 
 
 
 
5214
  {
5215
  "rule": "odoo.float-comparison-needs-float-utils",
5216
  "severity": "high",
@@ -7101,6 +7111,16 @@
7101
  "likelihood": "unmeasured",
7102
  "path": "addons/l10n_account_withholding_tax/wizards/account_payment_register_withholding_line.py"
7103
  },
 
 
 
 
 
 
 
 
 
 
7104
  {
7105
  "rule": "odoo.noupdate-on-user-editable-data",
7106
  "severity": "medium",
@@ -7311,16 +7331,6 @@
7311
  "likelihood": "construct",
7312
  "path": "addons/l10n_dk_nemhandel_response/models/account_move.py"
7313
  },
7314
- {
7315
- "rule": "odoo.onchange-is-not-validation",
7316
- "severity": "critical",
7317
- "line": 12,
7318
- "evidence": "@api.onchange '_onchange_rate_warning' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
7319
- "suppressed": false,
7320
- "advisory": false,
7321
- "likelihood": "unmeasured",
7322
- "path": "addons/l10n_eg_edi_eta/models/res_currency_rate.py"
7323
- },
7324
  {
7325
  "rule": "odoo.stored-compute-needs-complete-depends",
7326
  "severity": "critical",
@@ -8251,16 +8261,6 @@
8251
  "likelihood": "construct",
8252
  "path": "addons/l10n_in/models/account_invoice.py"
8253
  },
8254
- {
8255
- "rule": "odoo.onchange-is-not-validation",
8256
- "severity": "critical",
8257
- "line": 135,
8258
- "evidence": "@api.onchange '_onchange_name_warning' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
8259
- "suppressed": false,
8260
- "advisory": false,
8261
- "likelihood": "unmeasured",
8262
- "path": "addons/l10n_in/models/account_invoice.py"
8263
- },
8264
  {
8265
  "rule": "odoo.stored-compute-needs-complete-depends",
8266
  "severity": "critical",
@@ -9781,16 +9781,6 @@
9781
  "likelihood": "construct",
9782
  "path": "addons/l10n_se/models/account_move.py"
9783
  },
9784
- {
9785
- "rule": "odoo.onchange-is-not-validation",
9786
- "severity": "critical",
9787
- "line": 14,
9788
- "evidence": "@api.onchange 'onchange_l10n_se_default_vendor_payment_ref' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
9789
- "suppressed": false,
9790
- "advisory": false,
9791
- "likelihood": "unmeasured",
9792
- "path": "addons/l10n_se/models/res_partner.py"
9793
- },
9794
  {
9795
  "rule": "python.bare-except-catches-the-exit",
9796
  "severity": "high",
@@ -10091,6 +10081,16 @@
10091
  "likelihood": "defect",
10092
  "path": "addons/loyalty/controllers/portal.py"
10093
  },
 
 
 
 
 
 
 
 
 
 
10094
  {
10095
  "rule": "odoo.ondelete-is-a-decision",
10096
  "severity": "high",
@@ -12061,6 +12061,16 @@
12061
  "likelihood": "unmeasured",
12062
  "path": "addons/mrp/models/mrp_production.py"
12063
  },
 
 
 
 
 
 
 
 
 
 
12064
  {
12065
  "rule": "odoo.constrains-must-list-every-field-read",
12066
  "severity": "critical",
@@ -12331,6 +12341,16 @@
12331
  "likelihood": "construct",
12332
  "path": "addons/mrp/models/mrp_workorder.py"
12333
  },
 
 
 
 
 
 
 
 
 
 
12334
  {
12335
  "rule": "odoo.float-comparison-needs-float-utils",
12336
  "severity": "high",
@@ -12581,16 +12601,6 @@
12581
  "likelihood": "construct",
12582
  "path": "addons/mrp/models/stock_scrap.py"
12583
  },
12584
- {
12585
- "rule": "odoo.onchange-is-not-validation",
12586
- "severity": "critical",
12587
- "line": 51,
12588
- "evidence": "@api.onchange '_onchange_serial_number' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
12589
- "suppressed": false,
12590
- "advisory": false,
12591
- "likelihood": "unmeasured",
12592
- "path": "addons/mrp/models/stock_scrap.py"
12593
- },
12594
  {
12595
  "rule": "odoo.stored-compute-needs-complete-depends",
12596
  "severity": "critical",
@@ -12791,16 +12801,6 @@
12791
  "likelihood": "defect",
12792
  "path": "addons/mrp_subcontracting/models/stock_warehouse.py"
12793
  },
12794
- {
12795
- "rule": "odoo.onchange-is-not-validation",
12796
- "severity": "critical",
12797
- "line": 21,
12798
- "evidence": "@api.onchange 'onchange_picking_type_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
12799
- "suppressed": false,
12800
- "advisory": false,
12801
- "likelihood": "unmeasured",
12802
- "path": "addons/mrp_subcontracting_dropshipping/models/purchase.py"
12803
- },
12804
  {
12805
  "rule": "python.assert-is-not-validation",
12806
  "severity": "critical",
@@ -12831,26 +12831,6 @@
12831
  "likelihood": "unproven",
12832
  "path": "addons/payment/data/payment_cron.xml"
12833
  },
12834
- {
12835
- "rule": "odoo.onchange-is-not-validation",
12836
- "severity": "critical",
12837
- "line": 113,
12838
- "evidence": "@api.onchange '_onchange_warn_before_disabling_tokens' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
12839
- "suppressed": false,
12840
- "advisory": false,
12841
- "likelihood": "unmeasured",
12842
- "path": "addons/payment/models/payment_method.py"
12843
- },
12844
- {
12845
- "rule": "odoo.onchange-is-not-validation",
12846
- "severity": "critical",
12847
- "line": 146,
12848
- "evidence": "@api.onchange '_onchange_provider_ids_warn_before_attaching_payment_method' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
12849
- "suppressed": false,
12850
- "advisory": false,
12851
- "likelihood": "unmeasured",
12852
- "path": "addons/payment/models/payment_method.py"
12853
- },
12854
  {
12855
  "rule": "odoo.stored-compute-needs-complete-depends",
12856
  "severity": "critical",
@@ -12861,16 +12841,6 @@
12861
  "likelihood": "construct",
12862
  "path": "addons/payment/models/payment_provider.py"
12863
  },
12864
- {
12865
- "rule": "odoo.onchange-is-not-validation",
12866
- "severity": "critical",
12867
- "line": 258,
12868
- "evidence": "@api.onchange '_onchange_state_warn_before_disabling_tokens' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
12869
- "suppressed": false,
12870
- "advisory": false,
12871
- "likelihood": "unmeasured",
12872
- "path": "addons/payment/models/payment_provider.py"
12873
- },
12874
  {
12875
  "rule": "odoo.onchange-is-not-validation",
12876
  "severity": "critical",
@@ -14411,6 +14381,16 @@
14411
  "likelihood": "construct",
14412
  "path": "addons/product/models/product_pricelist_item.py"
14413
  },
 
 
 
 
 
 
 
 
 
 
14414
  {
14415
  "rule": "odoo.sql-needs-query-parameters",
14416
  "severity": "critical",
@@ -14481,16 +14461,6 @@
14481
  "likelihood": "construct",
14482
  "path": "addons/product/models/product_template_attribute_value.py"
14483
  },
14484
- {
14485
- "rule": "odoo.onchange-is-not-validation",
14486
- "severity": "critical",
14487
- "line": 25,
14488
- "evidence": "@api.onchange '_onchange_group_sale_pricelist' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
14489
- "suppressed": false,
14490
- "advisory": false,
14491
- "likelihood": "unmeasured",
14492
- "path": "addons/product/models/res_config_settings.py"
14493
- },
14494
  {
14495
  "rule": "odoo.sudo-bypasses-record-rules",
14496
  "severity": "critical",
@@ -14521,16 +14491,6 @@
14521
  "likelihood": "unmeasured",
14522
  "path": "addons/product/models/res_partner.py"
14523
  },
14524
- {
14525
- "rule": "odoo.onchange-is-not-validation",
14526
- "severity": "critical",
14527
- "line": 10,
14528
- "evidence": "@api.onchange '_onchange_rounding' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
14529
- "suppressed": false,
14530
- "advisory": false,
14531
- "likelihood": "unmeasured",
14532
- "path": "addons/product/models/uom_uom.py"
14533
- },
14534
  {
14535
  "rule": "odoo.stored-compute-needs-complete-depends",
14536
  "severity": "critical",
@@ -15211,6 +15171,16 @@
15211
  "likelihood": "construct",
15212
  "path": "addons/purchase/models/purchase_order.py"
15213
  },
 
 
 
 
 
 
 
 
 
 
15214
  {
15215
  "rule": "odoo.sudo-bypasses-record-rules",
15216
  "severity": "critical",
@@ -16511,6 +16481,16 @@
16511
  "likelihood": "construct",
16512
  "path": "addons/sale/models/sale_order.py"
16513
  },
 
 
 
 
 
 
 
 
 
 
16514
  {
16515
  "rule": "odoo.float-comparison-needs-float-utils",
16516
  "severity": "high",
@@ -18071,16 +18051,6 @@
18071
  "likelihood": "unproven",
18072
  "path": "addons/sale_project_stock/models/stock_picking.py"
18073
  },
18074
- {
18075
- "rule": "odoo.onchange-is-not-validation",
18076
- "severity": "critical",
18077
- "line": 25,
18078
- "evidence": "@api.onchange '_onchange_service_product_uom_qty' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
18079
- "suppressed": false,
18080
- "advisory": false,
18081
- "likelihood": "unmeasured",
18082
- "path": "addons/sale_purchase/models/sale_order_line.py"
18083
- },
18084
  {
18085
  "rule": "odoo.sudo-bypasses-record-rules",
18086
  "severity": "critical",
@@ -18761,16 +18731,6 @@
18761
  "likelihood": "construct",
18762
  "path": "addons/spreadsheet/models/spreadsheet_mixin.py"
18763
  },
18764
- {
18765
- "rule": "odoo.onchange-is-not-validation",
18766
- "severity": "critical",
18767
- "line": 464,
18768
- "evidence": "@api.onchange '_onchange_tracking' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
18769
- "suppressed": false,
18770
- "advisory": false,
18771
- "likelihood": "unmeasured",
18772
- "path": "addons/stock/models/product.py"
18773
- },
18774
  {
18775
  "rule": "odoo.float-comparison-needs-float-utils",
18776
  "severity": "high",
@@ -18831,16 +18791,6 @@
18831
  "likelihood": "defect",
18832
  "path": "addons/stock/models/product.py"
18833
  },
18834
- {
18835
- "rule": "odoo.onchange-is-not-validation",
18836
- "severity": "critical",
18837
- "line": 82,
18838
- "evidence": "@api.onchange '_onchange_sublocation' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
18839
- "suppressed": false,
18840
- "advisory": false,
18841
- "likelihood": "unmeasured",
18842
- "path": "addons/stock/models/product_strategy.py"
18843
- },
18844
  {
18845
  "rule": "odoo.sudo-bypasses-record-rules",
18846
  "severity": "critical",
@@ -19711,6 +19661,16 @@
19711
  "likelihood": "construct",
19712
  "path": "addons/stock/models/stock_picking.py"
19713
  },
 
 
 
 
 
 
 
 
 
 
19714
  {
19715
  "rule": "odoo.float-comparison-needs-float-utils",
19716
  "severity": "high",
@@ -20361,16 +20321,6 @@
20361
  "likelihood": "unmeasured",
20362
  "path": "addons/stock_delivery/wizard/choose_delivery_package.py"
20363
  },
20364
- {
20365
- "rule": "odoo.onchange-is-not-validation",
20366
- "severity": "critical",
20367
- "line": 38,
20368
- "evidence": "@api.onchange '_onchange_package_type_weight' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
20369
- "suppressed": false,
20370
- "advisory": false,
20371
- "likelihood": "unmeasured",
20372
- "path": "addons/stock_delivery/wizard/choose_delivery_package.py"
20373
- },
20374
  {
20375
  "rule": "odoo.stored-compute-needs-complete-depends",
20376
  "severity": "critical",
@@ -20861,6 +20811,16 @@
20861
  "likelihood": "defect",
20862
  "path": "addons/transifex/models/transifex_code_translation.py"
20863
  },
 
 
 
 
 
 
 
 
 
 
20864
  {
20865
  "rule": "odoo.noupdate-on-user-editable-data",
20866
  "severity": "medium",
@@ -21591,16 +21551,6 @@
21591
  "likelihood": "unmeasured",
21592
  "path": "addons/website_event_sale/controllers/main.py"
21593
  },
21594
- {
21595
- "rule": "odoo.onchange-is-not-validation",
21596
- "severity": "critical",
21597
- "line": 8,
21598
- "evidence": "@api.onchange '_onchange_event_sale_warning' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
21599
- "suppressed": false,
21600
- "advisory": false,
21601
- "likelihood": "unmeasured",
21602
- "path": "addons/website_event_sale/models/product_pricelist.py"
21603
- },
21604
  {
21605
  "rule": "odoo.sudo-bypasses-record-rules",
21606
  "severity": "critical",
@@ -22361,16 +22311,6 @@
22361
  "likelihood": "defect",
22362
  "path": "addons/website_sale/models/product_template.py"
22363
  },
22364
- {
22365
- "rule": "odoo.onchange-is-not-validation",
22366
- "severity": "critical",
22367
- "line": 32,
22368
- "evidence": "@api.onchange '_onchange_property_product_pricelist' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
22369
- "suppressed": false,
22370
- "advisory": false,
22371
- "likelihood": "unmeasured",
22372
- "path": "addons/website_sale/models/res_partner.py"
22373
- },
22374
  {
22375
  "rule": "odoo.ondelete-is-a-decision",
22376
  "severity": "high",
 
1
  {
2
+ "generated_utc": "2026-09-27T15:09:46+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "odoo"
 
631
  "likelihood": "unmeasured",
632
  "path": "addons/account/models/account_move.py"
633
  },
634
+ {
635
+ "rule": "odoo.onchange-is-not-validation",
636
+ "severity": "critical",
637
+ "line": 2384,
638
+ "evidence": "@api.onchange '_onchange_partner_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
639
+ "suppressed": false,
640
+ "advisory": false,
641
+ "likelihood": "unmeasured",
642
+ "path": "addons/account/models/account_move.py"
643
+ },
644
  {
645
  "rule": "odoo.constrains-must-list-every-field-read",
646
  "severity": "critical",
 
3751
  "likelihood": "unproven",
3752
  "path": "addons/delivery/models/sale_order.py"
3753
  },
 
 
 
 
 
 
 
 
 
 
3754
  {
3755
  "rule": "odoo.constrains-iterate-recordset",
3756
  "severity": "high",
 
5211
  "likelihood": "construct",
5212
  "path": "addons/hr_expense/models/hr_expense.py"
5213
  },
5214
+ {
5215
+ "rule": "odoo.onchange-is-not-validation",
5216
+ "severity": "critical",
5217
+ "line": 324,
5218
+ "evidence": "@api.onchange '_inverse_total_amount_currency' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
5219
+ "suppressed": false,
5220
+ "advisory": false,
5221
+ "likelihood": "unmeasured",
5222
+ "path": "addons/hr_expense/models/hr_expense.py"
5223
+ },
5224
  {
5225
  "rule": "odoo.float-comparison-needs-float-utils",
5226
  "severity": "high",
 
7111
  "likelihood": "unmeasured",
7112
  "path": "addons/l10n_account_withholding_tax/wizards/account_payment_register_withholding_line.py"
7113
  },
7114
+ {
7115
+ "rule": "odoo.onchange-is-not-validation",
7116
+ "severity": "critical",
7117
+ "line": 172,
7118
+ "evidence": "@api.onchange '_onchange_partner_journal' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
7119
+ "suppressed": false,
7120
+ "advisory": false,
7121
+ "likelihood": "unmeasured",
7122
+ "path": "addons/l10n_ar/models/account_move.py"
7123
+ },
7124
  {
7125
  "rule": "odoo.noupdate-on-user-editable-data",
7126
  "severity": "medium",
 
7331
  "likelihood": "construct",
7332
  "path": "addons/l10n_dk_nemhandel_response/models/account_move.py"
7333
  },
 
 
 
 
 
 
 
 
 
 
7334
  {
7335
  "rule": "odoo.stored-compute-needs-complete-depends",
7336
  "severity": "critical",
 
8261
  "likelihood": "construct",
8262
  "path": "addons/l10n_in/models/account_invoice.py"
8263
  },
 
 
 
 
 
 
 
 
 
 
8264
  {
8265
  "rule": "odoo.stored-compute-needs-complete-depends",
8266
  "severity": "critical",
 
9781
  "likelihood": "construct",
9782
  "path": "addons/l10n_se/models/account_move.py"
9783
  },
 
 
 
 
 
 
 
 
 
 
9784
  {
9785
  "rule": "python.bare-except-catches-the-exit",
9786
  "severity": "high",
 
10081
  "likelihood": "defect",
10082
  "path": "addons/loyalty/controllers/portal.py"
10083
  },
10084
+ {
10085
+ "rule": "odoo.onchange-is-not-validation",
10086
+ "severity": "critical",
10087
+ "line": 65,
10088
+ "evidence": "@api.onchange '_restrict_expiration_on_loyalty' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
10089
+ "suppressed": false,
10090
+ "advisory": false,
10091
+ "likelihood": "unmeasured",
10092
+ "path": "addons/loyalty/models/loyalty_card.py"
10093
+ },
10094
  {
10095
  "rule": "odoo.ondelete-is-a-decision",
10096
  "severity": "high",
 
12061
  "likelihood": "unmeasured",
12062
  "path": "addons/mrp/models/mrp_production.py"
12063
  },
12064
+ {
12065
+ "rule": "odoo.onchange-is-not-validation",
12066
+ "severity": "critical",
12067
+ "line": 915,
12068
+ "evidence": "@api.onchange '_onchange_product_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
12069
+ "suppressed": false,
12070
+ "advisory": false,
12071
+ "likelihood": "unmeasured",
12072
+ "path": "addons/mrp/models/mrp_production.py"
12073
+ },
12074
  {
12075
  "rule": "odoo.constrains-must-list-every-field-read",
12076
  "severity": "critical",
 
12341
  "likelihood": "construct",
12342
  "path": "addons/mrp/models/mrp_workorder.py"
12343
  },
12344
+ {
12345
+ "rule": "odoo.onchange-is-not-validation",
12346
+ "severity": "critical",
12347
+ "line": 446,
12348
+ "evidence": "@api.onchange '_onchange_date_finished' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
12349
+ "suppressed": false,
12350
+ "advisory": false,
12351
+ "likelihood": "unmeasured",
12352
+ "path": "addons/mrp/models/mrp_workorder.py"
12353
+ },
12354
  {
12355
  "rule": "odoo.float-comparison-needs-float-utils",
12356
  "severity": "high",
 
12601
  "likelihood": "construct",
12602
  "path": "addons/mrp/models/stock_scrap.py"
12603
  },
 
 
 
 
 
 
 
 
 
 
12604
  {
12605
  "rule": "odoo.stored-compute-needs-complete-depends",
12606
  "severity": "critical",
 
12801
  "likelihood": "defect",
12802
  "path": "addons/mrp_subcontracting/models/stock_warehouse.py"
12803
  },
 
 
 
 
 
 
 
 
 
 
12804
  {
12805
  "rule": "python.assert-is-not-validation",
12806
  "severity": "critical",
 
12831
  "likelihood": "unproven",
12832
  "path": "addons/payment/data/payment_cron.xml"
12833
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12834
  {
12835
  "rule": "odoo.stored-compute-needs-complete-depends",
12836
  "severity": "critical",
 
12841
  "likelihood": "construct",
12842
  "path": "addons/payment/models/payment_provider.py"
12843
  },
 
 
 
 
 
 
 
 
 
 
12844
  {
12845
  "rule": "odoo.onchange-is-not-validation",
12846
  "severity": "critical",
 
14381
  "likelihood": "construct",
14382
  "path": "addons/product/models/product_pricelist_item.py"
14383
  },
14384
+ {
14385
+ "rule": "odoo.onchange-is-not-validation",
14386
+ "severity": "critical",
14387
+ "line": 422,
14388
+ "evidence": "@api.onchange '_onchange_price_round' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
14389
+ "suppressed": false,
14390
+ "advisory": false,
14391
+ "likelihood": "unmeasured",
14392
+ "path": "addons/product/models/product_pricelist_item.py"
14393
+ },
14394
  {
14395
  "rule": "odoo.sql-needs-query-parameters",
14396
  "severity": "critical",
 
14461
  "likelihood": "construct",
14462
  "path": "addons/product/models/product_template_attribute_value.py"
14463
  },
 
 
 
 
 
 
 
 
 
 
14464
  {
14465
  "rule": "odoo.sudo-bypasses-record-rules",
14466
  "severity": "critical",
 
14491
  "likelihood": "unmeasured",
14492
  "path": "addons/product/models/res_partner.py"
14493
  },
 
 
 
 
 
 
 
 
 
 
14494
  {
14495
  "rule": "odoo.stored-compute-needs-complete-depends",
14496
  "severity": "critical",
 
15171
  "likelihood": "construct",
15172
  "path": "addons/purchase/models/purchase_order.py"
15173
  },
15174
+ {
15175
+ "rule": "odoo.onchange-is-not-validation",
15176
+ "severity": "critical",
15177
+ "line": 365,
15178
+ "evidence": "@api.onchange 'onchange_partner_id_warning' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
15179
+ "suppressed": false,
15180
+ "advisory": false,
15181
+ "likelihood": "unmeasured",
15182
+ "path": "addons/purchase/models/purchase_order.py"
15183
+ },
15184
  {
15185
  "rule": "odoo.sudo-bypasses-record-rules",
15186
  "severity": "critical",
 
16481
  "likelihood": "construct",
16482
  "path": "addons/sale/models/sale_order.py"
16483
  },
16484
+ {
16485
+ "rule": "odoo.onchange-is-not-validation",
16486
+ "severity": "critical",
16487
+ "line": 895,
16488
+ "evidence": "@api.onchange '_onchange_partner_id_warning' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
16489
+ "suppressed": false,
16490
+ "advisory": false,
16491
+ "likelihood": "unmeasured",
16492
+ "path": "addons/sale/models/sale_order.py"
16493
+ },
16494
  {
16495
  "rule": "odoo.float-comparison-needs-float-utils",
16496
  "severity": "high",
 
18051
  "likelihood": "unproven",
18052
  "path": "addons/sale_project_stock/models/stock_picking.py"
18053
  },
 
 
 
 
 
 
 
 
 
 
18054
  {
18055
  "rule": "odoo.sudo-bypasses-record-rules",
18056
  "severity": "critical",
 
18731
  "likelihood": "construct",
18732
  "path": "addons/spreadsheet/models/spreadsheet_mixin.py"
18733
  },
 
 
 
 
 
 
 
 
 
 
18734
  {
18735
  "rule": "odoo.float-comparison-needs-float-utils",
18736
  "severity": "high",
 
18791
  "likelihood": "defect",
18792
  "path": "addons/stock/models/product.py"
18793
  },
 
 
 
 
 
 
 
 
 
 
18794
  {
18795
  "rule": "odoo.sudo-bypasses-record-rules",
18796
  "severity": "critical",
 
19661
  "likelihood": "construct",
19662
  "path": "addons/stock/models/stock_picking.py"
19663
  },
19664
+ {
19665
+ "rule": "odoo.onchange-is-not-validation",
19666
+ "severity": "critical",
19667
+ "line": 1033,
19668
+ "evidence": "@api.onchange '_onchange_picking_type' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
19669
+ "suppressed": false,
19670
+ "advisory": false,
19671
+ "likelihood": "unmeasured",
19672
+ "path": "addons/stock/models/stock_picking.py"
19673
+ },
19674
  {
19675
  "rule": "odoo.float-comparison-needs-float-utils",
19676
  "severity": "high",
 
20321
  "likelihood": "unmeasured",
20322
  "path": "addons/stock_delivery/wizard/choose_delivery_package.py"
20323
  },
 
 
 
 
 
 
 
 
 
 
20324
  {
20325
  "rule": "odoo.stored-compute-needs-complete-depends",
20326
  "severity": "critical",
 
20811
  "likelihood": "defect",
20812
  "path": "addons/transifex/models/transifex_code_translation.py"
20813
  },
20814
+ {
20815
+ "rule": "odoo.onchange-is-not-validation",
20816
+ "severity": "critical",
20817
+ "line": 20,
20818
+ "evidence": "@api.onchange '_onchange_uom_ids' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
20819
+ "suppressed": false,
20820
+ "advisory": false,
20821
+ "likelihood": "unmeasured",
20822
+ "path": "addons/uom/models/uom_uom.py"
20823
+ },
20824
  {
20825
  "rule": "odoo.noupdate-on-user-editable-data",
20826
  "severity": "medium",
 
21551
  "likelihood": "unmeasured",
21552
  "path": "addons/website_event_sale/controllers/main.py"
21553
  },
 
 
 
 
 
 
 
 
 
 
21554
  {
21555
  "rule": "odoo.sudo-bypasses-record-rules",
21556
  "severity": "critical",
 
22311
  "likelihood": "defect",
22312
  "path": "addons/website_sale/models/product_template.py"
22313
  },
 
 
 
 
 
 
 
 
 
 
22314
  {
22315
  "rule": "odoo.ondelete-is-a-decision",
22316
  "severity": "high",
fieldtest-openspp-2026-09.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "generated_utc": "2026-09-27T14:46:49+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "OpenSPP2-19.0"
@@ -841,6 +841,16 @@
841
  "likelihood": "construct",
842
  "path": "spp_area/models/area_core.py"
843
  },
 
 
 
 
 
 
 
 
 
 
844
  {
845
  "rule": "odoo.stored-compute-needs-complete-depends",
846
  "severity": "critical",
@@ -1451,6 +1461,16 @@
1451
  "likelihood": "unproven",
1452
  "path": "spp_change_request_v2/models/change_request.py"
1453
  },
 
 
 
 
 
 
 
 
 
 
1454
  {
1455
  "rule": "odoo.prefer-sql-constraint",
1456
  "severity": "high",
@@ -2601,6 +2621,16 @@
2601
  "likelihood": "preference",
2602
  "path": "spp_import_match/models/import_match.py"
2603
  },
 
 
 
 
 
 
 
 
 
 
2604
  {
2605
  "rule": "odoo.ondelete-is-a-decision",
2606
  "severity": "high",
@@ -3101,6 +3131,16 @@
3101
  "likelihood": "unmeasured",
3102
  "path": "spp_registry/models/group.py"
3103
  },
 
 
 
 
 
 
 
 
 
 
3104
  {
3105
  "rule": "odoo.constrains-must-list-every-field-read",
3106
  "severity": "critical",
 
1
  {
2
+ "generated_utc": "2026-09-27T15:05:40+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "OpenSPP2-19.0"
 
841
  "likelihood": "construct",
842
  "path": "spp_area/models/area_core.py"
843
  },
844
+ {
845
+ "rule": "odoo.onchange-is-not-validation",
846
+ "severity": "critical",
847
+ "line": 88,
848
+ "evidence": "@api.onchange '_onchange_parent_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
849
+ "suppressed": false,
850
+ "advisory": false,
851
+ "likelihood": "unmeasured",
852
+ "path": "spp_area/models/area_core.py"
853
+ },
854
  {
855
  "rule": "odoo.stored-compute-needs-complete-depends",
856
  "severity": "critical",
 
1461
  "likelihood": "unproven",
1462
  "path": "spp_change_request_v2/models/change_request.py"
1463
  },
1464
+ {
1465
+ "rule": "odoo.onchange-is-not-validation",
1466
+ "severity": "critical",
1467
+ "line": 206,
1468
+ "evidence": "@api.onchange '_onchange_available_document_ids' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
1469
+ "suppressed": false,
1470
+ "advisory": false,
1471
+ "likelihood": "unmeasured",
1472
+ "path": "spp_change_request_v2/models/change_request_type.py"
1473
+ },
1474
  {
1475
  "rule": "odoo.prefer-sql-constraint",
1476
  "severity": "high",
 
2621
  "likelihood": "preference",
2622
  "path": "spp_import_match/models/import_match.py"
2623
  },
2624
+ {
2625
+ "rule": "odoo.onchange-is-not-validation",
2626
+ "severity": "critical",
2627
+ "line": 187,
2628
+ "evidence": "@api.onchange '_onchange_field_id' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
2629
+ "suppressed": false,
2630
+ "advisory": false,
2631
+ "likelihood": "unmeasured",
2632
+ "path": "spp_import_match/models/import_match.py"
2633
+ },
2634
  {
2635
  "rule": "odoo.ondelete-is-a-decision",
2636
  "severity": "high",
 
3131
  "likelihood": "unmeasured",
3132
  "path": "spp_registry/models/group.py"
3133
  },
3134
+ {
3135
+ "rule": "odoo.onchange-is-not-validation",
3136
+ "severity": "critical",
3137
+ "line": 71,
3138
+ "evidence": "@api.onchange '_membership_type_onchange' refuses a value, and onchange runs in the web client form only -- an import, an RPC call, a cron or a direct create() never triggers it, so the record enters the database unchallenged",
3139
+ "suppressed": false,
3140
+ "advisory": false,
3141
+ "likelihood": "unmeasured",
3142
+ "path": "spp_registry/models/group_membership.py"
3143
+ },
3144
  {
3145
  "rule": "odoo.constrains-must-list-every-field-read",
3146
  "severity": "critical",
tests/test_semantics.py CHANGED
@@ -1033,6 +1033,64 @@ def test_a_dead_sql_constraint_does_not_back_an_onchange_on_19():
1033
  {"odoo_series": "19.0"}) == []
1034
 
1035
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1036
  # ------------------------------------------------ the value-origin trace
1037
  #
1038
  # Four rules were classified false for one reason: each read a variable's NAME
 
1033
  {"odoo_series": "19.0"}) == []
1034
 
1035
 
1036
+ CREDIT_ONCHANGE = ('class M(models.Model):\n'
1037
+ '{constraint}'
1038
+ ' @api.onchange("credit_limit")\n'
1039
+ ' def _onchange_credit_limit(self):\n'
1040
+ ' if self.credit_limit < 0:\n'
1041
+ ' raise ValidationError("no")\n')
1042
+
1043
+
1044
+ @pytest.mark.parametrize("constraint", [
1045
+ ' _ref = models.Constraint("UNIQUE(reference)", "no")\n',
1046
+ ' _sql_constraints = [("ref", "UNIQUE(reference)", "no")]\n',
1047
+ ' @api.constrains("reference")\n def _check(self):\n pass\n',
1048
+ ' @api.constrains("partner_id.credit_limit")\n def _check(self):\n pass\n',
1049
+ ])
1050
+ def test_a_constraint_on_another_field_does_not_back_an_onchange(constraint):
1051
+ """A uniqueness rule on `reference` used to silence an onchange raising
1052
+ on `credit_limit`. A dotted @api.constrains name registers nothing."""
1053
+ code = CREDIT_ONCHANGE.format(constraint=constraint)
1054
+ assert semantics.run_check("onchange_tries_to_enforce", code), constraint
1055
+
1056
+
1057
+ @pytest.mark.parametrize("constraint", [
1058
+ ' _c = models.Constraint("CHECK(credit_limit >= 0)", "no")\n',
1059
+ ' @api.constrains("credit_limit")\n def _check(self):\n pass\n',
1060
+ ' @api.constrains(*FIELDS)\n def _check(self):\n pass\n',
1061
+ ' _sql_constraints = BASE + [("c", "CHECK(credit_limit >= 0)", "no")]\n',
1062
+ ])
1063
+ def test_a_constraint_on_a_watched_field_or_an_unreadable_one_backs_it(constraint):
1064
+ code = CREDIT_ONCHANGE.format(constraint=constraint)
1065
+ assert semantics.run_check("onchange_tries_to_enforce", code) == [], constraint
1066
+
1067
+
1068
+ WARNING_ONCHANGE = ('class M(models.Model):\n'
1069
+ ' @api.onchange("qty")\n'
1070
+ ' def _onchange_qty(self):\n'
1071
+ ' self.hint = False\n'
1072
+ ' if self.qty < 0:\n'
1073
+ '{branch}'
1074
+ ' return {{"warning": {{"title": "x", "message": "y"}}}}\n')
1075
+
1076
+
1077
+ @pytest.mark.parametrize("branch,refuses", [
1078
+ ("", False), # a notice
1079
+ (" self.qty = 0\n", True), # the watched field back
1080
+ (" self.update({'qty': 0})\n", True),
1081
+ (" self.partner_id = False\n", True), # a field cleared
1082
+ (" self.location_id = self.other\n", False), # a suggestion
1083
+ ])
1084
+ def test_a_warning_refuses_only_when_its_branch_takes_the_value_back(branch, refuses):
1085
+ """Most warning dicts on core inform: "this reference already exists",
1086
+ "the rate is far from the previous one". The user saves past them in the
1087
+ form as in an import, so nothing is enforced anywhere. `self.hint = False`
1088
+ outside the branch does not count: it is a UI flag, not the refusal."""
1089
+ code = WARNING_ONCHANGE.format(branch=branch)
1090
+ found = semantics.run_check("onchange_tries_to_enforce", code)
1091
+ assert bool(found) is refuses, (branch, found)
1092
+
1093
+
1094
  # ------------------------------------------------ the value-origin trace
1095
  #
1096
  # Four rules were classified false for one reason: each read a variable's NAME