DBax127 commited on
Commit
e9d9bcb
·
1 Parent(s): ae568da

rule: _sql_constraints is ignored from Odoo 19

Browse files

odoo.sql-constraints-are-ignored-from-19, from OpenSPP 208d9758 (#394):
a _sql_constraints block on a 19.0 module, which Odoo 19 logs once and
never creates, so duplicates were saved silently. An AST pass over the
file plus the manifest's series, legacy_sql_constraints_on_19. Reported
on 19+; silent on 16 to 18, where it is the only spelling; silent when a
walked tree pins no series (odoo core, 174 files); advisory per finding
when nothing was walked at all.

Fires nowhere at HEAD on the three trees, as expected: OpenSPP had
migrated, OCA and core are 18.0. OpenSPP's history holds the evidence:
it catches all four fixed instances (three in ca44b5e3, one in 208d9758)
on the files before their fix and none after, and nexa check reports the
#394 file at the line the fix removed. Graded defect: on 19 the attribute
does nothing, so every finding is a constraint that does not exist.

Also corrects "models.Constraint arrived in Odoo 17": 18.0 core has none
and the 18.0 ORM reference has no Constraints section; the 19.0 one does.
The rule cites it. First rule declaring odoo_versions >=19.0, which
exercises the context layer's per-rule series check; tests pin it.

Retrieval, index rebuilt: 28/28 at TOP_K 6, 26/28 in the top 3.
Corpus 28 rules. 277 of 277 true positives still reported.
ARCHITECTURE 3.62.

ARCHITECTURE.md CHANGED
@@ -3065,6 +3065,56 @@ with it: it was a comparison, the one shape that did not survive, and a checker
3065
  its own wrong sample. The composed-in Python rules gain nothing and lose nothing in their
3066
  classification: the verdicts are unchanged, only which of them are still reported.
3067
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3068
  ## 4. Performance traps, all found by measurement
3069
 
3070
  Within four instrumented runs the report showed the 7B at ~10 tok/s against a
@@ -3136,12 +3186,12 @@ in a corpus; it does not close the reasoning gap.
3136
 
3137
  ### Checking is partial, and the layers say where
3138
 
3139
- - 1 of 27 rules has no automated check at all. It returns **no verdict**, never a
3140
  false pass, and `nexa coverage` prints the reason.
3141
  - <!--fig:corpus.by_pattern-->9<!--/fig--> of the
3142
- <!--fig:corpus.checkable-->26<!--/fig--> checked rules are decided by a **regex over
3143
  generated code**. That catches textual signatures, not meaning. Code wrong in a way
3144
- no pattern describes passes silently. The other <!--fig:corpus.by_ast-->17<!--/fig-->
3145
  are decided by an AST pass (§3.12, §3.47, §3.54). The regexes
3146
  no longer see docstrings, which are emptied before matching; they still see every
3147
  other string literal, so a violation quoted inside a triple-quoted SQL string or a
@@ -3299,13 +3349,14 @@ unawares.
3299
 
3300
  ### Corpus provenance
3301
 
3302
- 25 of 27 rules cite upstream documentation. The rest rest on general knowledge and
3303
  deserve more scepticism. Most of the rules were written by an assistant rather than
3304
  harvested from production incidents, and rules drawn from real incidents are worth
3305
- more. Three now are: `commit-inside-a-loop` and `sql-needs-query-parameters` came
3306
  from an external ruleset disagreeing with this one (3.34), and
3307
- `depends-must-not-name-unread-fields` from a fix commit in OpenSPP's history (3.35).
3308
- Three of twenty-seven is not a change of method yet.
 
3309
 
3310
  This is the deepest unresolved problem in the design and no layer addresses it: the
3311
  validator and the thing being validated are the same class of system. Provenance
@@ -3317,7 +3368,7 @@ tracks sources, versions and checker identities. It cannot establish that a rule
3317
  Odoo, and nothing else (§3.46). Odoo rules were checked against 18.0 documentation and
3318
  its source; behaviour differs across major versions.
3319
 
3320
- One stack, 27 rules: 19 about the ORM and 8 about the Python language, which apply
3321
  because an Odoo module is a Python module. Both halves carry field evidence from two
3322
  ecosystems and a hand classification of every finding, and they are **not the same
3323
  quality** — <!--fig:cls.openspp.precision-->85<!--/fig-->% on the two flagship ORM
 
3065
  its own wrong sample. The composed-in Python rules gain nothing and lose nothing in their
3066
  classification: the verdicts are unchanged, only which of them are still reported.
3067
 
3068
+ ### 3.62 `_sql_constraints` is ignored from Odoo 19
3069
+
3070
+ **Measured 2026-09-26.** The rule an incident asked for, and the first new rule since the
3071
+ corpus reached 27. OpenSPP's 208d9758 (#394) found `spp.cr.type.reason.document` declaring
3072
+ its `(cr_type_id, reason)` uniqueness in `_sql_constraints` on a 19.0 module. Odoo 19 logs
3073
+ one warning at load and creates nothing, so duplicate rules were saved from 19.0.3.0.0
3074
+ until the fix, and the fix added a lint of its own because nothing in CI had caught it.
3075
+ `odoo.sql-constraints-are-ignored-from-19` is an AST pass over the file plus the manifest's
3076
+ series, `legacy_sql_constraints_on_19`.
3077
+
3078
+ **A version claim was wrong on the way in.** §3.45 and the `prefer-sql-constraint` rule said
3079
+ `models.Constraint` arrived in Odoo 17. Odoo 18.0 core at 3c3e3b3 has no `models.Constraint`
3080
+ anywhere and declares `_sql_constraints` in 174 files; the 18.0 ORM reference has no
3081
+ "Constraints and indexes" section and the 19.0 one does, declaring `Constraint`, `Index` and
3082
+ `UniqueIndex` and never naming `_sql_constraints`. So up to 18 the attribute is the only
3083
+ spelling, and from 19 it does nothing. The rule text, both correct samples that said "Odoo 16
3084
+ only", and the checker docstring that repeated it now say 16 to 18. §3.45 is left as it was
3085
+ written; this is its correction.
3086
+
3087
+ **Which series decides everything, and there are three answers.** 19 or later: reported, at
3088
+ the assignment. 16 to 18: silent, because a finding would be advice that cannot be taken.
3089
+ Unknown: silent when a tree was walked and simply does not pin a series, which is odoo core,
3090
+ whose manifests carry versions like "1.0" and would otherwise yield 174 findings, every one
3091
+ wrong. Reported as advisory, `unproven` on the finding itself, when nothing was walked at
3092
+ all, because "nobody looked" and "looked and it does not say" are different answers, and
3093
+ silence on the first would hide the rule from a bare snippet and from `validate`'s own samples.
3094
+ It is the first rule to declare `odoo_versions` other than `>=16.0`, and that exercised the
3095
+ context layer's per-rule check for the first time: grounding a 17.0 module with it is now a
3096
+ finding, which is what that check was written to say. A test pins both sides.
3097
+
3098
+ On the trees it fires nowhere, and that is the expected answer rather than a missing one.
3099
+ OpenSPP at the pinned commit already contains the fix and has 72 files of `models.Constraint`;
3100
+ OCA's 20 `_sql_constraints` are all in 18.0 modules; core is 18.0. So the evidence is
3101
+ OpenSPP's history. Five blocks were written on 19. Three were fixed together by `ca44b5e3`,
3102
+ *"migrate _sql_constraints to models.Constraint (Odoo 19)"*, one is #394, and one went with a
3103
+ reverted feature rather than a fix. The checker fires on all four fixed ones as the files stood
3104
+ before their fix and on none after, and `nexa check --no-model` on the #394 file inside its
3105
+ 19.0 module reports it as high impact, likelihood defect, at the line the fix removed.
3106
+
3107
+ It is graded **defect** on that, which is more than `noupdate` got for one incident in §3.55,
3108
+ and the difference is the construct rather than the count. A heuristic can be wrong about
3109
+ intent. This cannot: on 19 the attribute does nothing, so every finding is a constraint that
3110
+ does not exist. The one way to be wrong is a series misread, and that is why the unknown
3111
+ series is handled the way it is.
3112
+
3113
+ Its probe, *"after upgrading to Odoo 19 my unique constraint is not enforced and duplicates
3114
+ are saved"*, brings it back inside the top 3. Re-measured 2026-09-27 with the index rebuilt:
3115
+ all 28 rules retrievable at `TOP_K` 6, and 26 of 28 in the top 3, where it had been 25 of 27.
3116
+ The two that miss at 3 are `commit-inside-a-loop` and `eval-and-pickle`, not this one.
3117
+
3118
  ## 4. Performance traps, all found by measurement
3119
 
3120
  Within four instrumented runs the report showed the 7B at ~10 tok/s against a
 
3186
 
3187
  ### Checking is partial, and the layers say where
3188
 
3189
+ - 1 of <!--fig:corpus.rules-->28<!--/fig--> rules has no automated check at all. It returns **no verdict**, never a
3190
  false pass, and `nexa coverage` prints the reason.
3191
  - <!--fig:corpus.by_pattern-->9<!--/fig--> of the
3192
+ <!--fig:corpus.checkable-->27<!--/fig--> checked rules are decided by a **regex over
3193
  generated code**. That catches textual signatures, not meaning. Code wrong in a way
3194
+ no pattern describes passes silently. The other <!--fig:corpus.by_ast-->18<!--/fig-->
3195
  are decided by an AST pass (§3.12, §3.47, §3.54). The regexes
3196
  no longer see docstrings, which are emptied before matching; they still see every
3197
  other string literal, so a violation quoted inside a triple-quoted SQL string or a
 
3349
 
3350
  ### Corpus provenance
3351
 
3352
+ <!--fig:corpus.sourced-->26<!--/fig--> of <!--fig:corpus.rules-->28<!--/fig--> rules cite upstream documentation. The rest rest on general knowledge and
3353
  deserve more scepticism. Most of the rules were written by an assistant rather than
3354
  harvested from production incidents, and rules drawn from real incidents are worth
3355
+ more. Four now are: `commit-inside-a-loop` and `sql-needs-query-parameters` came
3356
  from an external ruleset disagreeing with this one (3.34), and
3357
+ `depends-must-not-name-unread-fields` from a fix commit in OpenSPP's history (3.35), and
3358
+ `sql-constraints-are-ignored-from-19` from an OpenSPP incident (3.62). Four of
3359
+ twenty-eight is not a change of method yet.
3360
 
3361
  This is the deepest unresolved problem in the design and no layer addresses it: the
3362
  validator and the thing being validated are the same class of system. Provenance
 
3368
  Odoo, and nothing else (§3.46). Odoo rules were checked against 18.0 documentation and
3369
  its source; behaviour differs across major versions.
3370
 
3371
+ One stack, <!--fig:corpus.rules-->28<!--/fig--> rules: 20 about the ORM and 8 about the Python language, which apply
3372
  because an Odoo module is a Python module. Both halves carry field evidence from two
3373
  ecosystems and a hand classification of every finding, and they are **not the same
3374
  quality** — <!--fig:cls.openspp.precision-->85<!--/fig-->% on the two flagship ORM
README.md CHANGED
@@ -135,7 +135,7 @@ publishing a classification.
135
 
136
  ## What it is not
137
 
138
- **Not a general linter.** 27 rules. It will not replace `pylint-odoo`, and after the
139
  wrapper above it does not have to.
140
 
141
  **Not a bug finder.** Against real Odoo bugfixes the corpus catches
@@ -427,7 +427,7 @@ the same command.
427
  It prints **no verdict**, because nothing generated an answer to have a verdict
428
  about. A clean run says *no pattern fired* in those words, and exits 1 if one did.
429
 
430
- **A `.py` or `.xml` file is reviewed against all 27 rules. Anything else is refused
431
  by name.**
432
 
433
  ```
@@ -583,10 +583,10 @@ Same principle as `z.infer<typeof Schema>`: one definition, derived artifacts, n
583
 
584
  | | |
585
  |---|---|
586
- | Rules | <!--fig:corpus.rules-->27<!--/fig--> — 19 Odoo ORM, 8 plain-Python, one stack |
587
  | Critical | <!--fig:corpus.critical-->12<!--/fig--> |
588
- | Machine-checkable | <!--fig:corpus.checkable-->26<!--/fig--> (<!--fig:corpus.checkable.pct-->96<!--/fig-->%): <!--fig:corpus.by_pattern-->9<!--/fig--> by pattern, <!--fig:corpus.by_ast-->17<!--/fig--> by AST |
589
- | Traceable to upstream docs | <!--fig:corpus.sourced-->25<!--/fig--> (<!--fig:corpus.sourced.pct-->93<!--/fig-->%), each audited for topic fit |
590
  | Pattern false positives | 0 cross-hits across the corpus |
591
  | Recall under meaning-preserving rewrite | 79/79 |
592
  | Satisfied patterns bypassable by a comment | 0 of <!--fig:corpus.satisfied_patterns-->1<!--/fig--> |
@@ -609,7 +609,7 @@ Same principle as `z.infer<typeof Schema>`: one definition, derived artifacts, n
609
  | `nexa check` verdict stability | 4 of 5 files on one run, 3 of 5 on the next |
610
  | `nexa check --no-model` | 0.26s, identical output every run |
611
 
612
- 26 of 27 rules carry an automated check. The one that does not —
613
  `schema-change-needs-migration`, whose violation lives in the diff between two module
614
  versions — returns **no verdict** rather than a false pass, and `nexa coverage` names
615
  it and says why.
@@ -1253,11 +1253,12 @@ nexa eval --retrieval
1253
  asks each rule its own probe and reports whether it comes back. No generation, so it
1254
  needs only the embedding model and finishes in seconds.
1255
 
1256
- All 27 are retrievable at the operating `TOP_K` of 6, and 25 of them rank in the top
1257
- 3. That number got harder rather than easier when the corpus became one stack: with
1258
- 19 rules competing, `prefer-sql-constraint` came back first for *"stop anyone entering
1259
- a negative quantity on a sale order line"*; with 27 it stopped coming back at all,
1260
- because that is the generic validation question every constraint rule here answers.
 
1261
  Reworded to the question only it answers — *"should this check be a database
1262
  constraint or an api.constrains method"* — and measured again. It is the third rule to
1263
  fail this way, and all three failed identically: correctly worded, in the vocabulary of
 
135
 
136
  ## What it is not
137
 
138
+ **Not a general linter.** <!--fig:corpus.rules-->28<!--/fig--> rules. It will not replace `pylint-odoo`, and after the
139
  wrapper above it does not have to.
140
 
141
  **Not a bug finder.** Against real Odoo bugfixes the corpus catches
 
427
  It prints **no verdict**, because nothing generated an answer to have a verdict
428
  about. A clean run says *no pattern fired* in those words, and exits 1 if one did.
429
 
430
+ **A `.py` or `.xml` file is reviewed against all <!--fig:corpus.rules-->28<!--/fig--> rules. Anything else is refused
431
  by name.**
432
 
433
  ```
 
583
 
584
  | | |
585
  |---|---|
586
+ | Rules | <!--fig:corpus.rules-->28<!--/fig--> — 19 Odoo ORM, 8 plain-Python, one stack |
587
  | Critical | <!--fig:corpus.critical-->12<!--/fig--> |
588
+ | Machine-checkable | <!--fig:corpus.checkable-->27<!--/fig--> (<!--fig:corpus.checkable.pct-->96<!--/fig-->%): <!--fig:corpus.by_pattern-->9<!--/fig--> by pattern, <!--fig:corpus.by_ast-->18<!--/fig--> by AST |
589
+ | Traceable to upstream docs | <!--fig:corpus.sourced-->26<!--/fig--> (<!--fig:corpus.sourced.pct-->93<!--/fig-->%), each audited for topic fit |
590
  | Pattern false positives | 0 cross-hits across the corpus |
591
  | Recall under meaning-preserving rewrite | 79/79 |
592
  | Satisfied patterns bypassable by a comment | 0 of <!--fig:corpus.satisfied_patterns-->1<!--/fig--> |
 
609
  | `nexa check` verdict stability | 4 of 5 files on one run, 3 of 5 on the next |
610
  | `nexa check --no-model` | 0.26s, identical output every run |
611
 
612
+ <!--fig:corpus.checkable-->27<!--/fig--> of <!--fig:corpus.rules-->28<!--/fig--> rules carry an automated check. The one that does not —
613
  `schema-change-needs-migration`, whose violation lives in the diff between two module
614
  versions — returns **no verdict** rather than a false pass, and `nexa coverage` names
615
  it and says why.
 
1253
  asks each rule its own probe and reports whether it comes back. No generation, so it
1254
  needs only the embedding model and finishes in seconds.
1255
 
1256
+ All 28 are retrievable at the operating `TOP_K` of 6, and 26 of them rank in the top
1257
+ 3, re-measured on 2026-09-27 with the `_sql_constraints` rule added. That number got
1258
+ harder rather than easier when the corpus became one stack: with 19 rules competing,
1259
+ `prefer-sql-constraint` came back first for *"stop anyone entering a negative quantity
1260
+ on a sale order line"*; with 27 it stopped coming back at all, because that is the
1261
+ generic validation question every constraint rule here answers.
1262
  Reworded to the question only it answers — *"should this check be a database
1263
  constraint or an api.constrains method"* — and measured again. It is the third rule to
1264
  fail this way, and all three failed identically: correctly worded, in the vocabulary of
bin/layers.py CHANGED
@@ -1083,9 +1083,10 @@ def _version_applies(declared, series):
1083
  comparators combine with AND, the rest with OR, and a series passes if it
1084
  satisfies every bound or matches any alternative.
1085
 
1086
- Nothing in the corpus exercises this yet -- all 19 versioned rules declare
1087
- the single clause '>=16.0'. It is settled here so the first bounded range
1088
- somebody writes means what they meant by it.
 
1089
  """
1090
  try:
1091
  target = float(series)
 
1083
  comparators combine with AND, the rest with OR, and a series passes if it
1084
  satisfies every bound or matches any alternative.
1085
 
1086
+ Every versioned rule declared the single clause '>=16.0' until
1087
+ odoo.sql-constraints-are-ignored-from-19, which declares '>=19.0' because
1088
+ 18.0 has no models.Constraint. It is settled here so the first bounded
1089
+ range somebody writes means what they meant by it.
1090
  """
1091
  try:
1092
  target = float(series)
bin/semantics.py CHANGED
@@ -648,11 +648,12 @@ def _owner_of(tree, fn):
648
  def _columns_already_constrained(tree, series=None):
649
  """{class node id: column names a SQL constraint on that class already covers}
650
 
651
- Two spellings, and only one of them still works everywhere.
652
- `_code_unique = models.Constraint("UNIQUE(code)", msg)` arrived in Odoo 17.
653
- `_sql_constraints = [(name, "UNIQUE(code)", msg)]` is the classic one and is
654
- IGNORED from 19: the registry logs a warning at load and the constraint is
655
- never created.
 
656
 
657
  So on a module that pins 19 or later, a _sql_constraints block is not a
658
  column being spoken for -- it is a column that nobody is watching and
@@ -694,6 +695,75 @@ def _assigns_name(statement, name):
694
  return any(isinstance(t, ast.Name) and t.id == name for t in statement.targets)
695
 
696
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  def _series_at_least(series, floor):
698
  """Is this module pinned to `floor` or later? False when nothing said."""
699
  try:
@@ -3392,6 +3462,7 @@ CHECKS = {
3392
  "parent_can_form_a_cycle": parent_can_form_a_cycle,
3393
  "editable_record_without_noupdate": editable_record_without_noupdate,
3394
  "naive_time_leaves_or_dates_a_day": naive_time_leaves_or_dates_a_day,
 
3395
  }
3396
 
3397
 
@@ -3408,6 +3479,9 @@ CONTEXT_CHECKS = frozenset([
3408
  # Reads odoo_series: whether a _sql_constraints block is a constraint at
3409
  # all depends on which Odoo is going to load it.
3410
  "prefer_sql_constraint_applies",
 
 
 
3411
  ])
3412
 
3413
  # Checkers that can meet input they cannot decide about -- a decorator whose
 
648
  def _columns_already_constrained(tree, series=None):
649
  """{class node id: column names a SQL constraint on that class already covers}
650
 
651
+ Two spellings, and neither works everywhere.
652
+ `_code_unique = models.Constraint("UNIQUE(code)", msg)` is the 19 one: odoo
653
+ 18.0 core has no models.Constraint at all and declares _sql_constraints in
654
+ 174 files. `_sql_constraints = [(name, "UNIQUE(code)", msg)]` is the classic
655
+ one and is IGNORED from 19: the registry logs a warning at load and the
656
+ constraint is never created.
657
 
658
  So on a module that pins 19 or later, a _sql_constraints block is not a
659
  column being spoken for -- it is a column that nobody is watching and
 
695
  return any(isinstance(t, ast.Name) and t.id == name for t in statement.targets)
696
 
697
 
698
+ def legacy_sql_constraints_on_19(code, facts=None):
699
+ """odoo.sql-constraints-are-ignored-from-19
700
+
701
+ A class-body `_sql_constraints` assignment that Odoo 19 will not read. From
702
+ 19 the registry logs one warning at load and creates nothing, so every
703
+ UNIQUE and CHECK in the block is a constraint nobody is enforcing and
704
+ everybody thinks somebody is.
705
+
706
+ The incident is OpenSPP's 208d9758 (#394): spp.cr.type.reason.document
707
+ declared (cr_type_id, reason) this way, duplicates were saved from
708
+ 19.0.3.0.0, and the fix re-declared it as models.Constraint and added a
709
+ lint of its own because nothing in CI had caught it. The rest of that tree
710
+ had already moved, 72 files of models.Constraint to this one holdout.
711
+
712
+ Which series, three answers:
713
+
714
+ 19 or later reported.
715
+ 16 to 18 silent. _sql_constraints is the ONLY spelling there --
716
+ odoo 18.0 core declares it in 174 files and has no
717
+ models.Constraint -- so a finding would be advice that
718
+ cannot be taken. OCA's twenty are all 18.0.
719
+ unknown silent when a tree was walked and does not pin a series,
720
+ which is odoo core itself: its manifests carry versions
721
+ like "1.0", and 174 findings there would all be wrong.
722
+ Reported, advisory, when NOTHING was walked -- a bare
723
+ snippet, validate's own samples -- because "nobody looked"
724
+ and "looked and it does not say" are different answers,
725
+ and silence on the first would hide the rule entirely.
726
+
727
+ An empty list declares nothing and is not reported.
728
+ """
729
+ facts = facts or {}
730
+ series = facts.get("odoo_series")
731
+ if _series_at_least(series, 19.0):
732
+ grade = None
733
+ elif series is None and not facts.get("walked"):
734
+ grade = UNPROVEN
735
+ else:
736
+ return []
737
+ tree, _ = parse_python(code)
738
+ if tree is None:
739
+ return []
740
+ findings = []
741
+ for cls in ast.walk(tree):
742
+ if not isinstance(cls, ast.ClassDef):
743
+ continue
744
+ for statement in cls.body:
745
+ if isinstance(statement, ast.Assign):
746
+ targets, value = statement.targets, statement.value
747
+ elif isinstance(statement, ast.AnnAssign) and statement.value is not None:
748
+ targets, value = [statement.target], statement.value
749
+ else:
750
+ continue
751
+ if not any(isinstance(t, ast.Name) and t.id == "_sql_constraints"
752
+ for t in targets):
753
+ continue
754
+ if isinstance(value, (ast.List, ast.Tuple)) and not value.elts:
755
+ continue
756
+ findings.append((
757
+ statement.lineno,
758
+ "{0} declares _sql_constraints, which Odoo 19 ignores{1}: the "
759
+ "registry logs a warning and creates no constraint, so nothing "
760
+ "enforces it -- declare each one as models.Constraint".format(
761
+ cls.name, "" if grade is None else
762
+ " (the series is unknown here; this holds from 19)"),
763
+ grade))
764
+ return findings
765
+
766
+
767
  def _series_at_least(series, floor):
768
  """Is this module pinned to `floor` or later? False when nothing said."""
769
  try:
 
3462
  "parent_can_form_a_cycle": parent_can_form_a_cycle,
3463
  "editable_record_without_noupdate": editable_record_without_noupdate,
3464
  "naive_time_leaves_or_dates_a_day": naive_time_leaves_or_dates_a_day,
3465
+ "legacy_sql_constraints_on_19": legacy_sql_constraints_on_19,
3466
  }
3467
 
3468
 
 
3479
  # Reads odoo_series: whether a _sql_constraints block is a constraint at
3480
  # all depends on which Odoo is going to load it.
3481
  "prefer_sql_constraint_applies",
3482
+ # Reads odoo_series, and whether a tree was walked at all: whether a
3483
+ # _sql_constraints block exists in the database depends on the series.
3484
+ "legacy_sql_constraints_on_19",
3485
  ])
3486
 
3487
  # Checkers that can meet input they cannot decide about -- a decorator whose
corpus/build_corpus.py CHANGED
@@ -56,7 +56,7 @@ def _onchange_qty(self):
56
  raise ValidationError('Quantity must be positive')""",
57
  "correct": """_qty_positive = models.Constraint("CHECK(qty > 0)", "Quantity must be positive.")
58
 
59
- # Odoo 16 only. From 19 this attribute is ignored and creates nothing:
60
  # _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]""",
61
  "tags": ["onchange", "constraints", "bypass"],
62
  # onchange is allowed as UX garnish, but only alongside a real enforcement
@@ -167,7 +167,7 @@ def _check_deadline(self):
167
  "odoo_versions": ">=16.0",
168
  "severity": "high",
169
  "title": "Prefer a database constraint when the rule fits in SQL",
170
- "rule": "If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint arrived in Odoo 17 and is the only spelling that still works at 19: the legacy _sql_constraints attribute is ignored there, the registry logs a warning at load time, and the constraint is silently never created. On 16, _sql_constraints is the spelling. A bound on one column is the common case: a quantity that must not be negative, a price or discount that must be positive, a percentage confined to 0-100, a minimum or maximum on any numeric field. A comparison between two columns of the same row also fits, with one caveat: PostgreSQL treats a CHECK as satisfied when its expression is NULL, so CHECK(date_end >= date_start) enforces nothing while either date is empty.",
171
  "why": "A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.",
172
  "enforcement_layer": "database",
173
  "bypassed_by": [],
@@ -178,12 +178,35 @@ def _check_unique_code(self):
178
  raise ValidationError('Code must be unique')""",
179
  "correct": """_code_uniq = models.Constraint("UNIQUE(code)", "Code must be unique.")
180
 
181
- # Odoo 16 only. From 19 this attribute is ignored and creates nothing:
182
  # _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]""",
183
  "tags": ["sql-constraints", "uniqueness", "race-condition", "check-constraint",
184
  "bounds", "range", "negative-value", "positive-value", "quantity",
185
  "minimum", "maximum"],
186
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  {
188
  "id": "odoo.check-constraint-cannot-cross-tables",
189
  "odoo_versions": ">=16.0",
@@ -613,6 +636,11 @@ def revoke(consent_id: str):
613
  # ignore is worse than no checker.
614
 
615
  ODOO_ORM = "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html"
 
 
 
 
 
616
  ODOO_DATA = "https://www.odoo.com/documentation/18.0/developer/reference/backend/data.html"
617
  ODOO_SECURITY = "https://www.odoo.com/documentation/18.0/developer/reference/backend/security.html"
618
  ODOO_EXTERNAL_API = "https://www.odoo.com/documentation/18.0/developer/reference/external_api.html"
@@ -710,6 +738,15 @@ ENRICH = {
710
  "semantic_check": "prefer_sql_constraint_applies",
711
  "source": ODOO_CONSTRAINTS,
712
  },
 
 
 
 
 
 
 
 
 
713
  "odoo.check-constraint-cannot-cross-tables": {
714
  "violation_pattern": r"CHECK\s*\([^)]*(?:SELECT|[a-z_]+_id\.[a-z_]+)",
715
  "source": PG_CREATE_TABLE,
@@ -1024,6 +1061,17 @@ LIKELIHOOD = {
1024
  # was 3 of 33 and 0 of 15.
1025
  "python.naive-and-aware-datetimes-do-not-compare": "defect",
1026
 
 
 
 
 
 
 
 
 
 
 
 
1027
  # 1 on-target of 14, 3 in the right method describing something else, and
1028
  # one commit caught. The same shape as ondelete's grade and a touch better
1029
  # aimed: the rule recognises a company-crossing relation and is about the
@@ -1109,6 +1157,8 @@ PROBES = {
1109
  # here is about a race.
1110
  "odoo.prefer-sql-constraint":
1111
  "should this check be a database constraint or an api.constrains method",
 
 
1112
  "odoo.check-constraint-cannot-cross-tables":
1113
  "my CHECK constraint will not install because it reads the product stock",
1114
  "odoo.check-company-on-relations":
 
56
  raise ValidationError('Quantity must be positive')""",
57
  "correct": """_qty_positive = models.Constraint("CHECK(qty > 0)", "Quantity must be positive.")
58
 
59
+ # Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:
60
  # _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]""",
61
  "tags": ["onchange", "constraints", "bypass"],
62
  # onchange is allowed as UX garnish, but only alongside a real enforcement
 
167
  "odoo_versions": ">=16.0",
168
  "severity": "high",
169
  "title": "Prefer a database constraint when the rule fits in SQL",
170
+ "rule": "If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint is the only spelling that works at 19: the legacy _sql_constraints attribute is ignored there, the registry logs a warning at load time, and the constraint is silently never created. On 16 to 18, _sql_constraints is the only spelling: 18.0 has no models.Constraint. A bound on one column is the common case: a quantity that must not be negative, a price or discount that must be positive, a percentage confined to 0-100, a minimum or maximum on any numeric field. A comparison between two columns of the same row also fits, with one caveat: PostgreSQL treats a CHECK as satisfied when its expression is NULL, so CHECK(date_end >= date_start) enforces nothing while either date is empty.",
171
  "why": "A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.",
172
  "enforcement_layer": "database",
173
  "bypassed_by": [],
 
178
  raise ValidationError('Code must be unique')""",
179
  "correct": """_code_uniq = models.Constraint("UNIQUE(code)", "Code must be unique.")
180
 
181
+ # Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:
182
  # _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]""",
183
  "tags": ["sql-constraints", "uniqueness", "race-condition", "check-constraint",
184
  "bounds", "range", "negative-value", "positive-value", "quantity",
185
  "minimum", "maximum"],
186
  },
187
+ {
188
+ "id": "odoo.sql-constraints-are-ignored-from-19",
189
+ "odoo_versions": ">=19.0",
190
+ "odoo_versions_basis": "Measured on the trees, not read off a page. Odoo 18.0 core (3c3e3b3) declares _sql_constraints in 174 files and has no models.Constraint anywhere, so the attribute is the only spelling up to 18. OpenSPP on 19.0 declares models.Constraint in 72 files, and its 208d9758 (#394) records that 19 logs a warning and creates nothing for _sql_constraints. The ORM reference agrees: the 19.0 page declares Constraint, Index and UniqueIndex under 'Constraints and indexes' and never names _sql_constraints, and the 18.0 page has no such section. The corpus is checked against 18.0; this is the one rule whose subject starts after it.",
191
+ "severity": "high",
192
+ "title": "_sql_constraints is ignored from Odoo 19",
193
+ "rule": "On a module for Odoo 19 or later, declare every UNIQUE and CHECK as a models.Constraint class attribute. A _sql_constraints list is not read at 19: the registry logs one warning at load and creates nothing. When moving one over, remove the duplicates already stored first, because a constraint that fails to apply during an upgrade is downgraded to a schema warning and stays missing.",
194
+ "why": "The constraint everybody reads in the class does not exist in the database, so the duplicates or out-of-range values it was written to refuse are saved without a word. The only trace is a warning in a log at every registry load. OpenSPP shipped exactly this on spp.cr.type.reason.document from 19.0.3.0.0, duplicate rules accumulated, and nothing in CI caught it.",
195
+ "enforcement_layer": "database",
196
+ "bypassed_by": [],
197
+ "wrong": """class ReasonDocument(models.Model):
198
+ _name = 'spp.cr.type.reason.document'
199
+
200
+ _sql_constraints = [
201
+ ('reason_uniq', 'UNIQUE(cr_type_id, reason)', 'One rule per reason.'),
202
+ ]""",
203
+ "correct": """class ReasonDocument(models.Model):
204
+ _name = 'spp.cr.type.reason.document'
205
+
206
+ _reason_uniq = models.Constraint('UNIQUE(cr_type_id, reason)', 'One rule per reason.')""",
207
+ "tags": ["sql-constraints", "migration", "odoo-19", "uniqueness", "check-constraint",
208
+ "silent-failure"],
209
+ },
210
  {
211
  "id": "odoo.check-constraint-cannot-cross-tables",
212
  "odoo_versions": ">=16.0",
 
636
  # ignore is worse than no checker.
637
 
638
  ODOO_ORM = "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html"
639
+ # The one rule whose subject starts after the series the corpus is checked
640
+ # against. The 19.0 page has a "Constraints and indexes" section declaring
641
+ # Constraint, Index and UniqueIndex and never names _sql_constraints; the 18.0
642
+ # page has no such section.
643
+ ODOO_ORM_19 = "https://www.odoo.com/documentation/19.0/developer/reference/backend/orm.html"
644
  ODOO_DATA = "https://www.odoo.com/documentation/18.0/developer/reference/backend/data.html"
645
  ODOO_SECURITY = "https://www.odoo.com/documentation/18.0/developer/reference/backend/security.html"
646
  ODOO_EXTERNAL_API = "https://www.odoo.com/documentation/18.0/developer/reference/external_api.html"
 
738
  "semantic_check": "prefer_sql_constraint_applies",
739
  "source": ODOO_CONSTRAINTS,
740
  },
741
+ "odoo.sql-constraints-are-ignored-from-19": {
742
+ # From an incident, not from the harvest: OpenSPP 208d9758 (#394)
743
+ # shipped a _sql_constraints block on 19, duplicates accumulated, and
744
+ # the fix added a lint of its own because nothing had caught it. The
745
+ # question is one file plus the manifest's series, so it is an AST
746
+ # pass reading odoo_series; see semantics.legacy_sql_constraints_on_19.
747
+ "semantic_check": "legacy_sql_constraints_on_19",
748
+ "source": ODOO_ORM_19,
749
+ },
750
  "odoo.check-constraint-cannot-cross-tables": {
751
  "violation_pattern": r"CHECK\s*\([^)]*(?:SELECT|[a-z_]+_id\.[a-z_]+)",
752
  "source": PG_CREATE_TABLE,
 
1061
  # was 3 of 33 and 0 of 15.
1062
  "python.naive-and-aware-datetimes-do-not-compare": "defect",
1063
 
1064
+ # Nothing live on the three trees -- OpenSPP had migrated, OCA and core
1065
+ # are 18.0 -- but OpenSPP's history holds every instance there was: five
1066
+ # blocks written on 19, three fixed together by ca44b5e3 ("migrate
1067
+ # _sql_constraints to models.Constraint"), one by 208d9758 (#394), one
1068
+ # gone with a reverted feature. The checker fires on all four fixed ones
1069
+ # as they were before the fix and on none after. And it cannot be wrong
1070
+ # the way a heuristic can: on 19 the attribute does nothing, so every
1071
+ # finding is a constraint that does not exist. A finding made without a
1072
+ # known series carries `unproven` on its own and gates nothing (3.62).
1073
+ "odoo.sql-constraints-are-ignored-from-19": "defect",
1074
+
1075
  # 1 on-target of 14, 3 in the right method describing something else, and
1076
  # one commit caught. The same shape as ondelete's grade and a touch better
1077
  # aimed: the rule recognises a company-crossing relation and is about the
 
1157
  # here is about a race.
1158
  "odoo.prefer-sql-constraint":
1159
  "should this check be a database constraint or an api.constrains method",
1160
+ "odoo.sql-constraints-are-ignored-from-19":
1161
+ "after upgrading to Odoo 19 my unique constraint is not enforced and duplicates are saved",
1162
  "odoo.check-constraint-cannot-cross-tables":
1163
  "my CHECK constraint will not install because it reads the product stock",
1164
  "odoo.check-company-on-relations":
corpus/corpus_meta.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
- "built_utc": "2026-09-26T21:51:05.417360+00:00",
3
- "rules": 27,
4
- "machine_checkable": 26,
5
  "by_pattern": 9,
6
- "by_semantic_check": 17,
7
  "checked_against": {
8
  "odoo": "18.0",
9
  "python": "3.12"
10
  },
11
- "corpus_sha256": "7a181b2b08454212bf80b533e5b5d5a7c1c229b9dac4e4a47b48a315fc7194bc"
12
  }
 
1
  {
2
+ "built_utc": "2026-09-27T11:31:01.417630+00:00",
3
+ "rules": 28,
4
+ "machine_checkable": 27,
5
  "by_pattern": 9,
6
+ "by_semantic_check": 18,
7
  "checked_against": {
8
  "odoo": "18.0",
9
  "python": "3.12"
10
  },
11
+ "corpus_sha256": "3ccc62bb28e3c26eaa15e272668fae5b24d6c0465d2a7e19733109815a32edb3"
12
  }
corpus/rules/odoo.json CHANGED
@@ -17,7 +17,7 @@
17
  "raw SQL"
18
  ],
19
  "wrong": "@api.onchange('qty')\ndef _onchange_qty(self):\n if self.qty <= 0:\n raise ValidationError('Quantity must be positive')",
20
- "correct": "_qty_positive = models.Constraint(\"CHECK(qty > 0)\", \"Quantity must be positive.\")\n\n# Odoo 16 only. From 19 this attribute is ignored and creates nothing:\n# _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]",
21
  "tags": [
22
  "onchange",
23
  "constraints",
@@ -133,12 +133,12 @@
133
  "odoo_versions": ">=16.0",
134
  "severity": "high",
135
  "title": "Prefer a database constraint when the rule fits in SQL",
136
- "rule": "If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint arrived in Odoo 17 and is the only spelling that still works at 19: the legacy _sql_constraints attribute is ignored there, the registry logs a warning at load time, and the constraint is silently never created. On 16, _sql_constraints is the spelling. A bound on one column is the common case: a quantity that must not be negative, a price or discount that must be positive, a percentage confined to 0-100, a minimum or maximum on any numeric field. A comparison between two columns of the same row also fits, with one caveat: PostgreSQL treats a CHECK as satisfied when its expression is NULL, so CHECK(date_end >= date_start) enforces nothing while either date is empty.",
137
  "why": "A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.",
138
  "enforcement_layer": "database",
139
  "bypassed_by": [],
140
  "wrong": "@api.constrains('code')\ndef _check_unique_code(self):\n for rec in self:\n if self.search_count([('code', '=', rec.code), ('id', '!=', rec.id)]):\n raise ValidationError('Code must be unique')",
141
- "correct": "_code_uniq = models.Constraint(\"UNIQUE(code)\", \"Code must be unique.\")\n\n# Odoo 16 only. From 19 this attribute is ignored and creates nothing:\n# _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]",
142
  "tags": [
143
  "sql-constraints",
144
  "uniqueness",
@@ -159,6 +159,33 @@
159
  "advisory": true,
160
  "probe": "should this check be a database constraint or an api.constrains method"
161
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  {
163
  "id": "odoo.check-constraint-cannot-cross-tables",
164
  "odoo_versions": ">=16.0",
 
17
  "raw SQL"
18
  ],
19
  "wrong": "@api.onchange('qty')\ndef _onchange_qty(self):\n if self.qty <= 0:\n raise ValidationError('Quantity must be positive')",
20
+ "correct": "_qty_positive = models.Constraint(\"CHECK(qty > 0)\", \"Quantity must be positive.\")\n\n# Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:\n# _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]",
21
  "tags": [
22
  "onchange",
23
  "constraints",
 
133
  "odoo_versions": ">=16.0",
134
  "severity": "high",
135
  "title": "Prefer a database constraint when the rule fits in SQL",
136
+ "rule": "If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint is the only spelling that works at 19: the legacy _sql_constraints attribute is ignored there, the registry logs a warning at load time, and the constraint is silently never created. On 16 to 18, _sql_constraints is the only spelling: 18.0 has no models.Constraint. A bound on one column is the common case: a quantity that must not be negative, a price or discount that must be positive, a percentage confined to 0-100, a minimum or maximum on any numeric field. A comparison between two columns of the same row also fits, with one caveat: PostgreSQL treats a CHECK as satisfied when its expression is NULL, so CHECK(date_end >= date_start) enforces nothing while either date is empty.",
137
  "why": "A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.",
138
  "enforcement_layer": "database",
139
  "bypassed_by": [],
140
  "wrong": "@api.constrains('code')\ndef _check_unique_code(self):\n for rec in self:\n if self.search_count([('code', '=', rec.code), ('id', '!=', rec.id)]):\n raise ValidationError('Code must be unique')",
141
+ "correct": "_code_uniq = models.Constraint(\"UNIQUE(code)\", \"Code must be unique.\")\n\n# Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:\n# _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]",
142
  "tags": [
143
  "sql-constraints",
144
  "uniqueness",
 
159
  "advisory": true,
160
  "probe": "should this check be a database constraint or an api.constrains method"
161
  },
162
+ {
163
+ "id": "odoo.sql-constraints-are-ignored-from-19",
164
+ "odoo_versions": ">=19.0",
165
+ "odoo_versions_basis": "Measured on the trees, not read off a page. Odoo 18.0 core (3c3e3b3) declares _sql_constraints in 174 files and has no models.Constraint anywhere, so the attribute is the only spelling up to 18. OpenSPP on 19.0 declares models.Constraint in 72 files, and its 208d9758 (#394) records that 19 logs a warning and creates nothing for _sql_constraints. The ORM reference agrees: the 19.0 page declares Constraint, Index and UniqueIndex under 'Constraints and indexes' and never names _sql_constraints, and the 18.0 page has no such section. The corpus is checked against 18.0; this is the one rule whose subject starts after it.",
166
+ "severity": "high",
167
+ "title": "_sql_constraints is ignored from Odoo 19",
168
+ "rule": "On a module for Odoo 19 or later, declare every UNIQUE and CHECK as a models.Constraint class attribute. A _sql_constraints list is not read at 19: the registry logs one warning at load and creates nothing. When moving one over, remove the duplicates already stored first, because a constraint that fails to apply during an upgrade is downgraded to a schema warning and stays missing.",
169
+ "why": "The constraint everybody reads in the class does not exist in the database, so the duplicates or out-of-range values it was written to refuse are saved without a word. The only trace is a warning in a log at every registry load. OpenSPP shipped exactly this on spp.cr.type.reason.document from 19.0.3.0.0, duplicate rules accumulated, and nothing in CI caught it.",
170
+ "enforcement_layer": "database",
171
+ "bypassed_by": [],
172
+ "wrong": "class ReasonDocument(models.Model):\n _name = 'spp.cr.type.reason.document'\n\n _sql_constraints = [\n ('reason_uniq', 'UNIQUE(cr_type_id, reason)', 'One rule per reason.'),\n ]",
173
+ "correct": "class ReasonDocument(models.Model):\n _name = 'spp.cr.type.reason.document'\n\n _reason_uniq = models.Constraint('UNIQUE(cr_type_id, reason)', 'One rule per reason.')",
174
+ "tags": [
175
+ "sql-constraints",
176
+ "migration",
177
+ "odoo-19",
178
+ "uniqueness",
179
+ "check-constraint",
180
+ "silent-failure"
181
+ ],
182
+ "stack": "odoo",
183
+ "semantic_check": "legacy_sql_constraints_on_19",
184
+ "source": "https://www.odoo.com/documentation/19.0/developer/reference/backend/orm.html",
185
+ "likelihood": "defect",
186
+ "advisory": false,
187
+ "probe": "after upgrading to Odoo 19 my unique constraint is not enforced and duplicates are saved"
188
+ },
189
  {
190
  "id": "odoo.check-constraint-cannot-cross-tables",
191
  "odoo_versions": ">=16.0",
fieldtest-oca-2026-09.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "generated_utc": "2026-09-26T21:44:44+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "server-tools",
@@ -13,7 +13,7 @@
13
  "lines": 33960,
14
  "xml_files": 223,
15
  "xml_lines": 12853,
16
- "checkers": 26,
17
  "triggers": {
18
  "files": 879,
19
  "models_declared": 139,
 
1
  {
2
+ "generated_utc": "2026-09-26T21:57:36+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "server-tools",
 
13
  "lines": 33960,
14
  "xml_files": 223,
15
  "xml_lines": 12853,
16
+ "checkers": 27,
17
  "triggers": {
18
  "files": 879,
19
  "models_declared": 139,
fieldtest-odoo-core-2026-09.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "generated_utc": "2026-09-26T21:50:13+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "odoo"
@@ -11,7 +11,7 @@
11
  "lines": 559663,
12
  "xml_files": 3119,
13
  "xml_lines": 322608,
14
- "checkers": 26,
15
  "triggers": {
16
  "files": 10252,
17
  "models_declared": 1269,
 
1
  {
2
+ "generated_utc": "2026-09-26T22:02:53+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "odoo"
 
11
  "lines": 559663,
12
  "xml_files": 3119,
13
  "xml_lines": 322608,
14
+ "checkers": 27,
15
  "triggers": {
16
  "files": 10252,
17
  "models_declared": 1269,
fieldtest-openspp-2026-09.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "generated_utc": "2026-09-26T21:46:57+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "OpenSPP2-19.0"
@@ -11,7 +11,7 @@
11
  "lines": 226937,
12
  "xml_files": 793,
13
  "xml_lines": 118020,
14
- "checkers": 26,
15
  "triggers": {
16
  "files": 2265,
17
  "models_declared": 492,
 
1
  {
2
+ "generated_utc": "2026-09-26T21:59:50+00:00",
3
  "stack": "odoo",
4
  "roots": [
5
  "OpenSPP2-19.0"
 
11
  "lines": 226937,
12
  "xml_files": 793,
13
  "xml_lines": 118020,
14
+ "checkers": 27,
15
  "triggers": {
16
  "files": 2265,
17
  "models_declared": 492,
modelfiles/gen-odoo.Modelfile CHANGED
@@ -103,7 +103,7 @@ garnish on top of the real rule. Agreeing here ships bad data to production.
103
  Correct:
104
  _qty_positive = models.Constraint("CHECK(qty > 0)", "Quantity must be positive.")
105
 
106
- # Odoo 16 only. From 19 this attribute is ignored and creates nothing:
107
  # _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]
108
 
109
  [odoo.schema-change-needs-migration] (critical) Schema changes need a migration script
@@ -290,7 +290,7 @@ garnish on top of the real rule. Agreeing here ships bad data to production.
290
  rec.label = rec.order_id.name
291
 
292
  [odoo.prefer-sql-constraint] (high) Prefer a database constraint when the rule fits in SQL
293
- Rule: If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint arrived in Odoo 17 and is the only spelling that still works at 19: the legacy _sql_constraints attribute is ignored there, the registry logs a warning at load time, and the constraint is silently never created. On 16, _sql_constraints is the spelling. A bound on one column is the common case: a quantity that must not be negative, a price or discount that must be positive, a percentage confined to 0-100, a minimum or maximum on any numeric field. A comparison between two columns of the same row also fits, with one caveat: PostgreSQL treats a CHECK as satisfied when its expression is NULL, so CHECK(date_end >= date_start) enforces nothing while either date is empty.
294
  Why: A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.
295
  Wrong:
296
  @api.constrains('code')
@@ -301,7 +301,7 @@ garnish on top of the real rule. Agreeing here ships bad data to production.
301
  Correct:
302
  _code_uniq = models.Constraint("UNIQUE(code)", "Code must be unique.")
303
 
304
- # Odoo 16 only. From 19 this attribute is ignored and creates nothing:
305
  # _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]
306
 
307
  [odoo.recursion-check-on-hierarchy] (high) parent_id hierarchies need a recursion check
@@ -317,6 +317,22 @@ garnish on top of the real rule. Agreeing here ships bad data to production.
317
  if self._has_cycle():
318
  raise ValidationError('You cannot create recursive hierarchies.')
319
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
  [python.bare-except-catches-the-exit] (high) A bare except swallows Ctrl-C and sys.exit too
321
  Rule: Catch the exceptions you can actually handle. Never write a bare except, and use except Exception only at a boundary that logs and re-raises.
322
  Why: A bare except catches KeyboardInterrupt and SystemExit as well, so Ctrl-C stops working and sys.exit() is ignored. It also hides a typo exactly as readily as the error it was written for, and the failure then surfaces much later as wrong data rather than as a traceback pointing at its cause.
@@ -410,13 +426,25 @@ garnish on top of the real rule. Agreeing here ships bad data to production.
410
  Rule: Inside the ORM, datetimes are naive UTC: Odoo pins the server process to UTC, so datetime.now() and fields.Datetime.now() are the same value, and an aware datetime written to a Datetime field raises ValueError. The defects are at the edges. A timestamp leaving the system -- an API response, a token, a file for another system -- carries its offset: datetime.now(timezone.utc). A value parsed with an offset is converted to naive UTC before it meets the ORM or a naive value. And a date meant as a user's or company's calendar day comes from fields.Date.context_today, not the server's UTC date.
411
  Why: Comparing a naive datetime with an aware one raises TypeError, so the two meet for the first time in whichever branch is exercised least -- usually expiry, usually in production. A naive timestamp handed to another system cannot say which zone it is in. And the server's date is UTC's date: for a company ahead of UTC, anything dated 'today' in its early hours is dated yesterday, which at month end is the previous period.
412
  Wrong:
413
- if expires_at < datetime.datetime.now():
414
- raise ValueError('token expired')
 
 
 
 
 
 
 
415
  Correct:
416
  from datetime import datetime, timezone
 
 
 
 
417
 
418
- if expires_at < datetime.now(timezone.utc):
419
- raise ValueError('token expired')
 
420
 
421
 
422
  === HOW TO ANSWER ===
 
103
  Correct:
104
  _qty_positive = models.Constraint("CHECK(qty > 0)", "Quantity must be positive.")
105
 
106
+ # Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:
107
  # _sql_constraints = [('qty_positive', 'CHECK(qty > 0)', 'Quantity must be positive.')]
108
 
109
  [odoo.schema-change-needs-migration] (critical) Schema changes need a migration script
 
290
  rec.label = rec.order_id.name
291
 
292
  [odoo.prefer-sql-constraint] (high) Prefer a database constraint when the rule fits in SQL
293
+ Rule: If a rule is expressible as UNIQUE or CHECK over columns of one table, declare it as models.Constraint rather than @api.constrains. models.Constraint is the only spelling that works at 19: the legacy _sql_constraints attribute is ignored there, the registry logs a warning at load time, and the constraint is silently never created. On 16 to 18, _sql_constraints is the only spelling: 18.0 has no models.Constraint. A bound on one column is the common case: a quantity that must not be negative, a price or discount that must be positive, a percentage confined to 0-100, a minimum or maximum on any numeric field. A comparison between two columns of the same row also fits, with one caveat: PostgreSQL treats a CHECK as satisfied when its expression is NULL, so CHECK(date_end >= date_start) enforces nothing while either date is empty.
294
  Why: A database constraint cannot be bypassed by any client, ORM path, raw SQL, or a future module writing with sudo -- but only if it exists. A _sql_constraints block on Odoo 19 creates nothing and warns in a log nobody reads, which is the same failure as the Python check it replaced, wearing the word constraint. A Python search_count uniqueness check also races under concurrency and lets duplicates through. A sign or range check written as @api.constrains is not wrong, but it is enforced one layer too high: the same CHECK in the schema holds for every writer, forever, including the ones written after you leave.
295
  Wrong:
296
  @api.constrains('code')
 
301
  Correct:
302
  _code_uniq = models.Constraint("UNIQUE(code)", "Code must be unique.")
303
 
304
+ # Odoo 16 to 18 only. From 19 this attribute is ignored and creates nothing:
305
  # _sql_constraints = [('code_uniq', 'UNIQUE(code)', 'Code must be unique.')]
306
 
307
  [odoo.recursion-check-on-hierarchy] (high) parent_id hierarchies need a recursion check
 
317
  if self._has_cycle():
318
  raise ValidationError('You cannot create recursive hierarchies.')
319
 
320
+ [odoo.sql-constraints-are-ignored-from-19] (high) _sql_constraints is ignored from Odoo 19
321
+ Rule: On a module for Odoo 19 or later, declare every UNIQUE and CHECK as a models.Constraint class attribute. A _sql_constraints list is not read at 19: the registry logs one warning at load and creates nothing. When moving one over, remove the duplicates already stored first, because a constraint that fails to apply during an upgrade is downgraded to a schema warning and stays missing.
322
+ Why: The constraint everybody reads in the class does not exist in the database, so the duplicates or out-of-range values it was written to refuse are saved without a word. The only trace is a warning in a log at every registry load. OpenSPP shipped exactly this on spp.cr.type.reason.document from 19.0.3.0.0, duplicate rules accumulated, and nothing in CI caught it.
323
+ Wrong:
324
+ class ReasonDocument(models.Model):
325
+ _name = 'spp.cr.type.reason.document'
326
+
327
+ _sql_constraints = [
328
+ ('reason_uniq', 'UNIQUE(cr_type_id, reason)', 'One rule per reason.'),
329
+ ]
330
+ Correct:
331
+ class ReasonDocument(models.Model):
332
+ _name = 'spp.cr.type.reason.document'
333
+
334
+ _reason_uniq = models.Constraint('UNIQUE(cr_type_id, reason)', 'One rule per reason.')
335
+
336
  [python.bare-except-catches-the-exit] (high) A bare except swallows Ctrl-C and sys.exit too
337
  Rule: Catch the exceptions you can actually handle. Never write a bare except, and use except Exception only at a boundary that logs and re-raises.
338
  Why: A bare except catches KeyboardInterrupt and SystemExit as well, so Ctrl-C stops working and sys.exit() is ignored. It also hides a typo exactly as readily as the error it was written for, and the failure then surfaces much later as wrong data rather than as a traceback pointing at its cause.
 
426
  Rule: Inside the ORM, datetimes are naive UTC: Odoo pins the server process to UTC, so datetime.now() and fields.Datetime.now() are the same value, and an aware datetime written to a Datetime field raises ValueError. The defects are at the edges. A timestamp leaving the system -- an API response, a token, a file for another system -- carries its offset: datetime.now(timezone.utc). A value parsed with an offset is converted to naive UTC before it meets the ORM or a naive value. And a date meant as a user's or company's calendar day comes from fields.Date.context_today, not the server's UTC date.
427
  Why: Comparing a naive datetime with an aware one raises TypeError, so the two meet for the first time in whichever branch is exercised least -- usually expiry, usually in production. A naive timestamp handed to another system cannot say which zone it is in. And the server's date is UTC's date: for a company ahead of UTC, anything dated 'today' in its early hours is dated yesterday, which at month end is the previous period.
428
  Wrong:
429
+ from datetime import datetime
430
+ from fastapi import APIRouter
431
+
432
+ router = APIRouter()
433
+
434
+
435
+ @router.delete("/consent/{consent_id}")
436
+ def revoke(consent_id: str):
437
+ return {"consent_id": consent_id, "revoked_at": datetime.utcnow().isoformat()}
438
  Correct:
439
  from datetime import datetime, timezone
440
+ from fastapi import APIRouter
441
+
442
+ router = APIRouter()
443
+
444
 
445
+ @router.delete("/consent/{consent_id}")
446
+ def revoke(consent_id: str):
447
+ return {"consent_id": consent_id, "revoked_at": datetime.now(timezone.utc).isoformat()}
448
 
449
 
450
  === HOW TO ANSWER ===
tests/test_layers.py CHANGED
@@ -131,13 +131,27 @@ def test_context_mismatched_series_is_a_gap_and_not_a_finding(odoo_rules):
131
  rules carries an odoo_versions field, so the blanket warning was standing in
132
  for per-rule data that does not exist yet.
133
  """
134
- result = layers.layer_context(odoo_rules, _ctx(odoo_series="17.0",
135
- manifest="__manifest__.py"))
 
 
136
  assert result.findings == []
137
  assert any("17.0" in why and "18.0" in why for _, why in result.not_checked)
138
  # And it is not UNKNOWN either, because every rule now declares the series it
139
  # holds for, so there is a question here this layer can actually answer.
140
  assert result.verdict == layers.PASS
 
 
 
 
 
 
 
 
 
 
 
 
141
  assert any("odoo_versions" in c for c in result.checked)
142
 
143
 
 
131
  rules carries an odoo_versions field, so the blanket warning was standing in
132
  for per-rule data that does not exist yet.
133
  """
134
+ applying = [r for r in odoo_rules
135
+ if layers._version_applies(r.get("odoo_versions") or ">=16.0", "17.0")]
136
+ result = layers.layer_context(applying, _ctx(odoo_series="17.0",
137
+ manifest="__manifest__.py"))
138
  assert result.findings == []
139
  assert any("17.0" in why and "18.0" in why for _, why in result.not_checked)
140
  # And it is not UNKNOWN either, because every rule now declares the series it
141
  # holds for, so there is a question here this layer can actually answer.
142
  assert result.verdict == layers.PASS
143
+
144
+
145
+ def test_a_retrieved_rule_for_a_later_series_is_a_finding(odoo_rules):
146
+ """The per-rule check, exercised by a real rule for the first time: the
147
+ _sql_constraints rule holds from 19, and grounding a 17.0 module with it
148
+ would tell the model to use a spelling 17.0 does not have."""
149
+ later = [r for r in odoo_rules if r["id"] == "odoo.sql-constraints-are-ignored-from-19"]
150
+ result = layers.layer_context(later, _ctx(odoo_series="17.0",
151
+ manifest="__manifest__.py"))
152
+ assert [f.rule for f in result.findings] == ["odoo.sql-constraints-are-ignored-from-19"]
153
+ assert layers.layer_context(later, _ctx(odoo_series="19.0",
154
+ manifest="__manifest__.py")).findings == []
155
  assert any("odoo_versions" in c for c in result.checked)
156
 
157
 
tests/test_semantics.py CHANGED
@@ -1659,3 +1659,48 @@ def test_datetime_reports_the_two_shapes_that_survived(why, body):
1659
  ])
1660
  def test_datetime_leaves_the_shapes_that_did_not(why, body):
1661
  assert semantics.run_check("naive_time_leaves_or_dates_a_day", body) == [], why
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1659
  ])
1660
  def test_datetime_leaves_the_shapes_that_did_not(why, body):
1661
  assert semantics.run_check("naive_time_leaves_or_dates_a_day", body) == [], why
1662
+
1663
+
1664
+ _LEGACY = ("from odoo import models\n\n\n"
1665
+ "class ReasonDocument(models.Model):\n"
1666
+ " _name = 'spp.cr.type.reason.document'\n\n"
1667
+ " _sql_constraints = [\n"
1668
+ " ('reason_uniq', 'UNIQUE(cr_type_id, reason)', 'One rule per reason.'),\n"
1669
+ " ]\n")
1670
+
1671
+
1672
+ def test_sql_constraints_on_19_are_a_constraint_that_does_not_exist():
1673
+ """OpenSPP's 208d9758: the block sat on a 19.0 module and created nothing."""
1674
+ found = semantics.run_check("legacy_sql_constraints_on_19", _LEGACY,
1675
+ facts={"walked": True, "odoo_series": "19.0"})
1676
+ assert [(line, grade) for line, _, grade in found] == [(7, None)]
1677
+
1678
+
1679
+ def test_sql_constraints_before_19_are_the_only_spelling():
1680
+ """18.0 core declares the attribute in 174 files and has no
1681
+ models.Constraint, so a finding there is advice that cannot be taken."""
1682
+ for series in ("16.0", "17.0", "18.0"):
1683
+ assert semantics.run_check("legacy_sql_constraints_on_19", _LEGACY,
1684
+ facts={"walked": True, "odoo_series": series}) == []
1685
+
1686
+
1687
+ def test_sql_constraints_series_unknown_after_a_walk_is_silent():
1688
+ """odoo core's own manifests pin no series; 174 findings there would all
1689
+ be wrong."""
1690
+ assert semantics.run_check("legacy_sql_constraints_on_19", _LEGACY,
1691
+ facts={"walked": True, "odoo_series": None}) == []
1692
+
1693
+
1694
+ def test_sql_constraints_with_nothing_walked_is_advisory():
1695
+ """Nobody looked is not the same answer as looked and it does not say."""
1696
+ found = semantics.run_check("legacy_sql_constraints_on_19", _LEGACY)
1697
+ assert [grade for _, _, grade in found] == [semantics.UNPROVEN]
1698
+
1699
+
1700
+ def test_an_empty_sql_constraints_list_declares_nothing():
1701
+ body = ("from odoo import models\n\n\n"
1702
+ "class Thing(models.Model):\n"
1703
+ " _name = 'my.thing'\n"
1704
+ " _sql_constraints = []\n")
1705
+ assert semantics.run_check("legacy_sql_constraints_on_19", body,
1706
+ facts={"walked": True, "odoo_series": "19.0"}) == []