DBax127 commited on
Commit
210b09e
·
1 Parent(s): 3432acf

precision: read the named false positives, and the misses that named themselves

Browse files

Six false-positive causes the README had written down, closed by cause rather
than by rule, because the six had three causes between them.

Four rules matched a variable's NAME and read that as evidence about its value.
semantics.py grew a local value trace -- what a scope binds, what each name was
assigned, whether an expression is certainly an integer, whether its root came
from outside the file -- and float-comparison, path-join and decimal-for-money
moved onto it. onchange-is-not-validation had a different fault: the regex read
400 characters past the decorator and could not see where the method ended, so
all three of its false positives were a short onchange followed by an action
method that raises. Scoped to the decorated function by an AST pass.

Docstrings are emptied before matching, quotes and geometry preserved. A
suppression marker that names a worded code now covers only rules sharing one
of those words; an opaque code keeps the line as before, and the gap that
leaves is named in _marker_is_about. check_compliance runs semantic checkers
too, having silently lost every rule that moved to AST.

sudo-bypasses-record-rules now checks four clauses instead of one. The fourth,
`self = self.sudo()`, came out of the harvest: it is the miss the README names
by hand, and it is an on-target catch. The company_id exemption became the
fields the model's own ir.rule is written about, which is the narrowing 3.40
called a worklist entry and could not do without guessing.

Recall, re-measured over three ecosystems and 1,164 fix commits: 6% on OpenSPP
(was 4%), 6% on OCA (was 2%), 5% on odoo core. Nine more rules graded from
recall --aim, seven of them unproven on the evidence of firing nowhere in
820,000 lines -- which needed the third tree, because four rules that looked
identical on two trees fire on core.

Two new classifications, 84 findings read by hand. sudo is 0 true of 24. odoo
core is the first sample rather than census here, 60 findings across the four
rules making 70% of its output: ondelete 13/15, stored-compute 11/15, assert
3/15, sudo 0/15. The spread is the finding -- precision depends on what kind of
code is under review, and a framework's asserts have no outside to come from.

Seven checker defects fell out of reading: sudo(False), sudo(flag=False) and
sudo(self.env.su) read as escalation; display_name treated as maybe-stored when
the framework declares it unstored; _auto = False models treated as having a
foreign key; a statement-span tie that lost a marker; and scan_source matching
prose in docstrings.

244 of 244 hand-classified true positives still found, checked by re-running
against the same checkouts rather than by reading the diff.

ARCHITECTURE.md CHANGED
@@ -2170,12 +2170,12 @@ framework's answer to precision is `float_round`/`float_compare`, not `Decimal`.
2170
  rule fires on the idiom and names a fix that cannot be taken. Three findings do not
2171
  settle a checker either way, so it is recorded rather than removed.
2172
 
2173
- **Two defects the classification found that no test would have.** `scan_source` strips
2174
  `#` comments before matching and not string literals, so `eval()` named in a docstring
2175
- reads as code. And suppression reads any marker within a simple statement without
2176
- asking which rule it names, so a `# nosemgrep: odoo-sudo-without-context` silenced a
2177
  datetime finding four lines above it — the mute button of §3.42 reached from the other
2178
- side. Both are named in the README rather than quietly fixed.
2179
 
2180
  **What the id prefix means now.** `python.decimal-for-money-not-float` carries
2181
  `stack: odoo`. The prefix is **provenance** — where the rule came from and which
@@ -2192,6 +2192,413 @@ stopped being retrieved at all against 27 — that probe is the generic validati
2192
  question every constraint rule here answers. Reworded to the one only it answers.
2193
  **27 of 27 retrievable, 25 in the top 3.**
2194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2195
  ## 4. Performance traps, all found by measurement
2196
 
2197
  Within four instrumented runs the report showed the 7B at ~10 tok/s against a
@@ -2265,11 +2672,17 @@ in a corpus; it does not close the reasoning gap.
2265
 
2266
  - 1 of 27 rules has no automated check at all. It returns **no verdict**, never a
2267
  false pass, and `nexa coverage` prints the reason.
2268
- - 16 of the 26 checked rules are decided by a **regex over generated code**. That
2269
  catches textual signatures, not meaning. Code wrong in a way no pattern describes
2270
- passes silently. The other 10 are decided by an AST pass (§3.12). The regexes also
2271
- match inside **docstrings**, because `scan_source` strips `#` comments and not
2272
- string literals — two of the fifty findings in §3.46 were that.
 
 
 
 
 
 
2273
  - Semantic analysis is **Python only**, which is the whole subject now. An answer may
2274
  still contain a JavaScript block, Odoo's web client being OWL; that block gets
2275
  delimiter balance and nothing deeper, and the layer reports UNKNOWN rather than
 
2170
  rule fires on the idiom and names a fix that cannot be taken. Three findings do not
2171
  settle a checker either way, so it is recorded rather than removed.
2172
 
2173
+ **Two defects the classification found that no test would have.** `scan_source` stripped
2174
  `#` comments before matching and not string literals, so `eval()` named in a docstring
2175
+ read as code. And suppression read any marker within a simple statement without
2176
+ asking which rule it named, so a `# nosemgrep: odoo-sudo-without-context` silenced a
2177
  datetime finding four lines above it — the mute button of §3.42 reached from the other
2178
+ side. Both were named in the README first, and both are closed in §3.47.
2179
 
2180
  **What the id prefix means now.** `python.decimal-for-money-not-float` carries
2181
  `stack: odoo`. The prefix is **provenance** — where the rule came from and which
 
2192
  question every constraint rule here answers. Reworded to the one only it answers.
2193
  **27 of 27 retrievable, 25 in the top 3.**
2194
 
2195
+ ### 3.47 Reading the value instead of the name, and the rest of the sudo sentence
2196
+
2197
+ **Decided 2026-09-21.** Six false-positive causes the README had named were closed in
2198
+ one pass, and the pass was ordered by cause rather than by rule, because the six had
2199
+ three causes between them.
2200
+
2201
+ **The shared cause.** `float-comparison-needs-float-utils` (2 true, 5 false),
2202
+ `path-join-does-not-contain-a-path` (0 of 4) and `decimal-for-money-not-float` (0 of 3)
2203
+ each matched a variable's *name* and treated that as evidence about its *value*. All
2204
+ five float falses compared an integer to zero — `search_count`, a `read_group`
2205
+ `<field>_count`, `cr.fetchone()[0]` of a `SELECT COUNT(*)`, a `fields.Integer` — and
2206
+ three of the five plus both true positives were spelled `total == 0`. No name could ever
2207
+ have separated them. The assignment can.
2208
+
2209
+ So `semantics.py` grew a local value trace: which names a scope binds, what each was
2210
+ assigned, whether an expression is certainly a whole number, whether its root came from
2211
+ outside the file. One file, one function, no imports followed, no types inferred, and
2212
+ the limit is written into the limitations section rather than discovered later. Three
2213
+ regexes became AST passes on top of it. Every answer the trace gives that *suppresses* a
2214
+ finding must be certain — a name assigned twice is an integer only if both assignments
2215
+ are — because a suppressing guess is the direction that hides.
2216
+
2217
+ **The window that crossed a function.** `onchange-is-not-validation` was 4 true, 3
2218
+ false, and the README's reading of the three ("the write is to a Many2one") was wrong.
2219
+ Each is a four-line onchange that assigns a field, followed by an action method that
2220
+ raises. The regex read `[\s\S]{0,400}` past the decorator; `_onchange_variable_id` is
2221
+ 200 characters long and the `UserError` it matched belongs to `action_remap`. A regex
2222
+ has no way not to do that. Scoped to the decorated function by an AST pass, the three
2223
+ are silent, and three new findings surfaced and were read: a phone-number validation
2224
+ living only in an onchange with no constraint anywhere in the file; a
2225
+ `_check_date_collected` onchange raising `exceptions.ValidationError`, a dotted spelling
2226
+ the regex's `\w*` never matched; and one that clears a field and returns the warning
2227
+ dict. The finding anchors on the decorator, because that is where the rule is and where
2228
+ a marker about it gets written — a `FunctionDef`'s own `lineno` is the `def`, one line
2229
+ down, and reporting there lost a suppression a test already relied on.
2230
+
2231
+ **Two infrastructure defects, fixed once.** Docstrings are now emptied before any
2232
+ pattern sees the file — docstrings only, since `_SQL_COLUMNS` reads triple-quoted SQL
2233
+ on purpose — with quotes, newlines and length preserved so no reported line or column
2234
+ moves. And a suppression marker that *names* a code with words in it
2235
+ (`odoo-sudo-without-context`) now covers only rules sharing one of those words, vendor
2236
+ and language words excepted; a code with no words (`B608`, `E501`) or prose after the
2237
+ colon cannot be compared with anything and covers its line exactly as before. The
2238
+ direction of error is stated in `_marker_is_about`: a code the function cannot relate
2239
+ to a rule leaves the finding reported.
2240
+
2241
+ Fixing that surfaced a third defect nothing had reached before. Every statement span
2242
+ borrows the comment line above it, so consecutive statements overlap by one line, and a
2243
+ finding on the first line of one statement is also inside the borrowed line of the next.
2244
+ Two spans of equal width tied, and the winner was whichever `ast.walk` reached first.
2245
+ On `spp_api_v2/wizards/show_secret_wizard.py` the `return` on line 38 won the marker on
2246
+ line 36 away from the write on 37. A span whose statement *covers* the line now beats
2247
+ one that only borrows it.
2248
+
2249
+ **The rest of the sudo sentence.** The rule says three things — scope `sudo()` to the
2250
+ narrowest operation, re-apply company filtering by hand, never pass a sudo recordset
2251
+ onward — and the checker tested only `.sudo().search()`. §3.30's harvest put `.sudo(` at
2252
+ 70 missed against 6 caught, 47 of the 70 in files where a checker fired elsewhere; the
2253
+ README's own named miss, *"remove sudo() when importing record"*, is a `create`. Counted
2254
+ on the OpenSPP tree, the query clause has 116 call sites and the unchecked one 136.
2255
+ `sudo_search_drops_a_real_rule` became `sudo_drops_record_rules` and checks all three:
2256
+ queries, touches (`create`, `write`, `unlink`, `copy`, `read`), and a sudo recordset
2257
+ bound to a name and then returned or passed as an argument — the bare name only, since
2258
+ telling `return p.filtered(...)` from `return p.mapped('name')` means knowing which
2259
+ recordset methods return recordsets, and a new clause on a critical rule should
2260
+ under-report.
2261
+
2262
+ A query decides *which* records exist for you and a touch acts on ones you already hold.
2263
+ That line is where the measurement fell, so it is where the code divides.
2264
+
2265
+ The record-rule trigger of §3.40 gates every clause, and it needed one more thing to do
2266
+ so: `Plan.sudo().create(...)` names no model, so the trigger read UNKNOWN and could not
2267
+ suppress. The receiver is now followed through its assignment — `Plan =
2268
+ self.env["spp.case.plan"]` a few lines up — and 12 of the 15 unresolved receivers in the
2269
+ file that produced them resolve. `check_compliance` also runs semantic checkers now; it
2270
+ ran patterns only, so every rule that moved to an AST pass had silently left the
2271
+ compliance check of generated answers, including the onchange rule that function's own
2272
+ docstring gives as its reason for existing.
2273
+
2274
+ **The `company_id` exemption became the rule's own fields.** The old test for "this call
2275
+ already re-applied the scoping" was the literal word `company_id`, from the rule's
2276
+ correct sample, and §3.40 recorded the cost: 14 of 20 surviving findings were scoped by
2277
+ a group, a partner or an area and read as unscoped. It called a narrowing a worklist
2278
+ entry because "deciding in general that a domain is already scoped means evaluating it,
2279
+ and a narrowing that guesses would cost the 6 true positives this keeps."
2280
+
2281
+ There is a version that does not guess. `context` already parses every `ir.rule` in the
2282
+ tree to decide whether one filters; it now also records **which fields each domain is
2283
+ written about**, and the checker compares the call against those instead of against one
2284
+ hardcoded word. Not one OpenSPP case model has a company rule at all — they are governed
2285
+ by `[('case_worker_id', '=', user.id)]` and `[('case_id.case_worker_id', '=', user.id)]`
2286
+ — so a create naming `case_worker_id` or `case_id` lands inside the caller's own
2287
+ visibility, and that is the rule's advice followed in the only spelling that model has.
2288
+ `company_id` stays as the fallback where the rules could not be read, so nothing exempt
2289
+ before stops being exempt now.
2290
+
2291
+ Two details cost a true positive each before they were right. The comparison reads the
2292
+ call's **arguments**, not the unparsed call: the receiver carries the model name, and
2293
+ `spp.group.membership` contains the word `group`, which is one of that model's own rule
2294
+ fields — it exempted an unscoped sweep of every membership in the database, classified
2295
+ true by hand. And an argument that is a bare name is followed to what it was assigned,
2296
+ because `create(vals)` otherwise hides every field it sets behind four letters. All six
2297
+ true positives survive, checked explicitly.
2298
+
2299
+ **What the re-run says**, against the artifacts of §3.43 and §3.46:
2300
+
2301
+ | | OpenSPP before | after | OCA before | after |
2302
+ |---|---|---|---|---|
2303
+ | `float-comparison` | 7 | **2** | 1 | **3** |
2304
+ | `onchange-is-not-validation` | 7 | 7, all three falses replaced | — | — |
2305
+ | `decimal-for-money` | 2 | **0** | 1 | **0** |
2306
+ | `path-join` | 1 | **0** | 3 | **0** |
2307
+ | `eval-and-pickle` (docstring) | 1 | **0** | — | — |
2308
+ | `datetimes` wrongly suppressed | 1 | **0** | — | — |
2309
+ | `sudo` deciding / advisory / decided | 2 / 0 / 62 | **2 / 11 / 117** | 7 / 0 / 0 | **6 / 10 / 0** |
2310
+ | Rate per thousand lines | 0.9 | **0.8** | 3.5 | **3.4** |
2311
+
2312
+ The OCA float number went *up*, and that is the old `satisfied_pattern` clearing a
2313
+ whole file on a `float_compare` anywhere in it. The three are `Monetary` and `Float`
2314
+ fields compared with `!= 0` in files that call `float_compare` somewhere else, each read.
2315
+
2316
+ **What the classification says, and what was done about it.** All 23 live sudo findings
2317
+ were read by hand against their own files, in `classification-sudo-2026-09.json`.
2318
+ **0 true, 23 false.** The touch and escape clauses have not met a real one in 260,897
2319
+ lines. Every finding they made is code where the caller is an administrator by
2320
+ construction — a demo-data seeder, an accounting wizard behind
2321
+ `group_account_manager` — or a framework model with no tenant boundary: an
2322
+ `ir.actions.act_window`, an `ir.sequence.date_range`, a postgres tuning row.
2323
+
2324
+ So the two new clauses emit `unproven` **per finding**. That grade already exists for
2325
+ exactly this position — "a real defect nothing measured here has met yet, so it is
2326
+ reported and not trusted to stop a build" — and `scan_source` now derives a hit's
2327
+ `advisory` flag from the hit's own grade as well as its rule's, which is the same
2328
+ derivation the corpus builder already does one level up. The query clause keeps the
2329
+ rule's `defect` and its 6 true positives, because a grade travels with the finding.
2330
+
2331
+ Not deleted, and not by path. Skipping `*_demo` modules would be a heuristic invented to
2332
+ make a number smaller; grading a clause on what it was measured at is the thing this
2333
+ project does everywhere else. `touches-a-record-already-held` is 7 of the 23 and
2334
+ `scoped-to-a-record-already-held` an eighth of the same shape — a recordset the caller
2335
+ obtained under its own rules, then written under sudo. That is not nothing, because
2336
+ `ir.rule` governs write permission as well as visibility, but it is a different claim
2337
+ from the one the rule's `why` paragraph makes. Whether it is a second rule or a
2338
+ narrowing wants a tree where it fires on production code, and neither of these two is
2339
+ that tree.
2340
+
2341
+ ### 3.48 A fourth sudo clause, taken from the missed list
2342
+
2343
+ **Decided 2026-09-21.** The harvest is supposed to be a worklist, so it was used as one.
2344
+
2345
+ `--harvest` over the OCA trees puts `.sudo(` at the top again, and one row in it is the
2346
+ miss the README had been naming by hand since §3.30: server-tools `cbefaa511485`,
2347
+ *"[FIX] excel_import_export: remove sudo() when importing record"*. The diff deletes one
2348
+ line.
2349
+
2350
+ ```python
2351
+ self = self.sudo()
2352
+ ```
2353
+
2354
+ Nothing below it mentions sudo. Every clause in §3.47 looks for `.sudo().<method>()` or
2355
+ a sudo recordset handed to something, and this is neither: it **replaces the receiver
2356
+ the whole method is written against**, so each subsequent `self.env[...]` inherits the
2357
+ elevated environment while naming nothing. The commit says what it cost — *"finding
2358
+ multiple records across all companies during import when they have same record name.
2359
+ Previously, importing with sudo could bypass multi-company record rules"* — which is the
2360
+ rule's own `why` paragraph, written by somebody who had just been bitten by it.
2361
+
2362
+ Three sites in 260,000 lines, so the clause is narrow as well as real. Run against that
2363
+ commit's own before and after images the finding sits on line 283, is gone afterwards,
2364
+ and 283 is one of the two lines the fix touched: an on-target catch by recall's own
2365
+ definition, which is why this clause carries no grade of its own and keeps the rule's
2366
+ `defect` where the touch clause takes `unproven`.
2367
+
2368
+ The same commit **adds** `ModelData = self.sudo().env["ir.model.data"]`, a deliberate
2369
+ and narrowly scoped elevation. That is the evidence for not also flagging `.sudo().env[`,
2370
+ and it is worth stating: the author of the fix reached for the shape one clause would
2371
+ have reported, in the commit that fixed the thing another clause did report.
2372
+
2373
+ ### 3.49 What the re-measurement says, and the grades that came out of it
2374
+
2375
+ **Measured 2026-09-21**, over 795 fix commits across both ecosystems.
2376
+
2377
+ | | OpenSPP before | after | OCA before | after |
2378
+ |---|---|---|---|---|
2379
+ | Fix commits examined | 406 | 409 | 385 | 386 |
2380
+ | …touching a covered construct | 160 | **173** | 56 | **64** |
2381
+ | Caught | 6 | **11** | 1 | **4** |
2382
+ | Recall against in-scope fixes | 4% | **6%** | 2% | **6%** |
2383
+
2384
+ Both ecosystems land on the same number from different directions, which is worth more
2385
+ than either figure alone. The in-scope denominators rose too, so the improvement is not
2386
+ an artefact of measuring less.
2387
+
2388
+ `.sudo(` remains the largest cluster on OpenSPP but a different shape: **67 missed, 9
2389
+ caught, 52 spoke, 15 silent**, against 70/6/47/23 before. The silent column is what the
2390
+ new clauses ate. `noupdate` — 22 missed, 0 caught, 9 spoke, 13 silent — is now the
2391
+ cluster with the most silence against the least coverage, and is next.
2392
+
2393
+ **Four rules earned a grade.** `recall --aim` placed every finding a checker made in a
2394
+ file a fix touched against the method the fix edited, and the placements decide:
2395
+
2396
+ | rule | on-target | same-symbol | other | caught | grade |
2397
+ |---|---|---|---|---|---|
2398
+ | `sql-needs-query-parameters` | 3 | 5 | 12 | 1 | **defect** |
2399
+ | `naive-and-aware-datetimes-do-not-compare` | 2 | 5 | 16 | 2 | **defect** |
2400
+ | `check-company-on-relations` | 1 | 3 | 10 | 1 | **construct** |
2401
+ | `constrains-must-list-every-field-read` | 1 | 2 | 22 | 0 | **construct** |
2402
+
2403
+ The two `construct` grades are the same reading `ondelete` and `stored-compute` already
2404
+ have: the rule recognises the declaration and is about the method rather than the line
2405
+ anybody fixes.
2406
+
2407
+ ### 3.50 A third ecosystem, and the two things it changed
2408
+
2409
+ **Measured 2026-09-21.** Every recall number this project had came from two trees, and
2410
+ the README's own caveat was that two repositories in one ecosystem is a narrow sample.
2411
+ So the pass was run again against **odoo core 18.0** — 559,663 lines, 190,217 commits,
2412
+ 4,368 `.sudo(` call sites — with the recall half capped at 400 fix commits.
2413
+
2414
+ | | OpenSPP | OCA | odoo core | all three |
2415
+ |---|---|---|---|---|
2416
+ | Fix commits examined | 409 | 386 | 369 | **1,164** |
2417
+ | …touching a covered construct | 173 | 64 | 97 | **334** |
2418
+ | Caught | 11 | 4 | 5 | **20** |
2419
+ | Recall | 6% | 6% | 5% | **6%** |
2420
+ | Live findings per 1000 lines | 0.8 | 3.5 | **5.0** | — |
2421
+
2422
+ Three ecosystems landing on the same recall figure from different directions is the
2423
+ first evidence this project has that ~6% is a property of the corpus rather than of a
2424
+ sample. The precision rate on core is the highest of the three and is **not classified**;
2425
+ core is the reference implementation, so a rate there is a statement about the rules
2426
+ rather than about somebody's house style, and reading those 2,784 findings is the
2427
+ largest single piece of work this repository now has outstanding.
2428
+
2429
+ **It caught a checker defect that two trees could not.** `self = self.sudo(False)`
2430
+ turns record rules back **on**. It is how a method that must run under the caller's own
2431
+ rights says so, and odoo core does it 60 times — including mail's
2432
+ `_check_attachments_access`, whose docstring reads *"This method relies on access
2433
+ rules/rights and therefore it should not be called from a sudo env."* Every sudo clause
2434
+ read `.sudo` and ignored its argument, so the checker reported the safeguard as the
2435
+ defect. Neither OpenSPP nor OCA contains a single `sudo(False)`, so no amount of work on
2436
+ those two trees would have found it. `_escalates()` now reads the argument, and a
2437
+ non-literal argument still counts as an escalation because `sudo(flag)` can be either.
2438
+
2439
+ **It stopped a grading mistake.** Measured on OpenSPP and OCA alone, eleven rules had
2440
+ fired nowhere at all, and `unproven` — "a real defect nothing measured here has met yet"
2441
+ — is literally accurate for a rule like that. Four of the eleven fire on odoo core:
2442
+ `bare-except-catches-the-exit` 28 times, `mutable-default-argument` 15,
2443
+ `eval-and-pickle-execute-their-input` 4 and `subprocess-shell-true-is-injection` 4.
2444
+ Grading off two trees would have quietly downgraded four working rules on the strength
2445
+ of not having looked far enough.
2446
+
2447
+ The seven that fire nowhere across all three — 820,000 lines and 1,164 fix commits —
2448
+ are graded `unproven` on that evidence, which is more than the one rule already carrying
2449
+ the grade had. Two of the seven, `decimal-for-money-not-float` and `path-join-does-not-
2450
+ contain-a-path`, are silent because §3.47 narrowed them rather than because nothing was
2451
+ there; both readings are in the README and the grade records the second.
2452
+
2453
+ Sixteen rules are now graded and eleven are `unmeasured`, which remains the accurate
2454
+ word for them rather than a placeholder.
2455
+
2456
+ **One thing found, measured, and then not acted on.** Odoo core documents intentional
2457
+ elevation with its own comment convention — `# sudo: discuss.channel - guests don't
2458
+ have access for creating mail.message` — and uses it 136 times. The first reading was
2459
+ that this is the same artefact as a `# nosemgrep` and that recognising it would
2460
+ materially improve the rate on core.
2461
+
2462
+ The second half of that is simply false, and counting says so: of the **538** sudo
2463
+ findings on core, **6** carry a `# sudo:` note within three lines. One percent. The 136
2464
+ notes sit overwhelmingly on elevations no clause here fires on — the record-rule trigger
2465
+ has already dropped those, which is the trigger working. So the practical case for
2466
+ reading the convention evaporates, and what is left is only the argument of principle.
2467
+
2468
+ That argument is worth writing down anyway, because it is the one that decides whether
2469
+ any future marker gets added. A `# nosemgrep` records that *an analyser made a specific
2470
+ claim and a human rejected that claim*. A `# sudo:` records that *the author, while
2471
+ writing the line, noted why they wanted it*. Those are different kinds of evidence: the
2472
+ first is a response to a challenge, the second is the intention of the person who chose
2473
+ the code. This corpus exists for defects that **look correct in review** — the headline
2474
+ measurement is that 17% of OpenSPP's fixes were silent until the data was visibly wrong
2475
+ — so an author's stated intention is close to the thing the tool is meant to second-guess,
2476
+ not evidence against the finding. Treating it as a suppression would also be the
2477
+ nexa-specific mute button §3.42 refuses to build, reached through a third party's
2478
+ comment style.
2479
+
2480
+ The convention stays unread, and the reason is the principle rather than the number.
2481
+
2482
+ There is a general rule in that, and it is worth stating because this file is otherwise
2483
+ an argument for measuring everything. A question that turns on **interpretation** does
2484
+ not become settled by being measured. The 1% above is a fact about two checkouts on one
2485
+ day; the thing actually in dispute is what a comment is evidence *of*, and no rerun
2486
+ answers that. Pinning a number to it would produce a reference that drifts on every tree
2487
+ and every rerun while the disagreement underneath it stays exactly where it was. Measure
2488
+ to check a claim already made — that is how the 1% corrected an overclaim in this very
2489
+ section — but do not manufacture a measurement in place of a decision.
2490
+
2491
+ ### 3.51 Reading odoo core, and the first classification that is a sample
2492
+
2493
+ **Measured 2026-09-21.** §3.50 left core's findings unclassified and called reading them
2494
+ the largest outstanding piece of work. Reading all 2,784 is not a session's work, and
2495
+ pretending otherwise would have produced a worse artifact than admitting it.
2496
+
2497
+ So this is the first classification here that is a **sample rather than a census**: 15
2498
+ findings of the largest rule — `stored-compute-needs-complete-depends`, 1,051 of the
2499
+ 2,784 — drawn with a fixed seed and read by hand. The draw is reproducible from the
2500
+ artifact's own description, without the script that made it. **11 true, 4 false**, which
2501
+ is 73% with a 95% Wilson interval of roughly 48% to 89%. The interval is wide and it is
2502
+ the honest number for n=15: it says the rule is not mostly noise on core and it does not
2503
+ say much more.
2504
+
2505
+ **All four false verdicts have one cause.** `odoo/models.py` adds `display_name` to
2506
+ every model as `fields.Char(automatic=True, compute='_compute_display_name',
2507
+ search=...)` with **no store**. An override that omits a dependency therefore cannot
2508
+ leave a stale row, because there is no row. The checker read "no declaration in this
2509
+ file" as unknown storedness and kept the finding, which is the right reading for an
2510
+ ordinary field declared in another module and the wrong one here: the framework's own
2511
+ declaration is knowable and says not stored. 121 of the rule's 1,051 findings on core
2512
+ were this shape.
2513
+
2514
+ The narrowing is free, and that was checked rather than assumed. Ten hand-verified true
2515
+ positives compute `display_name` — every one of them in OpenSPP — and every one declares
2516
+ `store=True` on it **in its own file**, so in-file storedness reads True and they are
2517
+ untouched. Re-run: 244 of 244 hand-classified true positives across all five artifacts
2518
+ still found, none lost.
2519
+
2520
+ **The bigger thing the sample found, and did not act on.** 837 of odoo core's 1,360
2521
+ stored computes are `readonly=False`: the "compute a smart default, then let the user own
2522
+ it" pattern, where a deliberately narrow `@api.depends` protects a user's edit from being
2523
+ clobbered. That is a plausible reading of most of this rule's volume on core, and it is
2524
+ not a safe narrowing. Three of the 47 resolvable hand-verified true positives on OCA and
2525
+ OpenSPP are themselves `readonly=False`, and nine of the eleven true verdicts in this
2526
+ very sample are too. The pattern is a consideration inside each verdict, not a rule, and
2527
+ the number that would have justified treating it as one is the number that refutes it.
2528
+
2529
+ **What is still unread.** 1,612 live findings on core. The next three by volume are read
2530
+ in §3.52.
2531
+
2532
+ ### 3.52 The other three rules on core, and what precision turned out to depend on
2533
+
2534
+ **Measured 2026-09-21.** Same method as §3.51 and the same seed: 15 findings each of
2535
+ `sudo-bypasses-record-rules`, `assert-is-not-validation` and `ondelete-is-a-decision`,
2536
+ drawn and read by hand. With §3.51 that is 60 of core's findings across the four rules
2537
+ making 70% of them, in `classification-odoo-core-2026-09.json`.
2538
+
2539
+ | rule | core | previously | 95% Wilson |
2540
+ |---|---|---|---|
2541
+ | `ondelete-is-a-decision` | **13/15** | 12/12 OCA, 14/14 OpenSPP | 60–95% |
2542
+ | `stored-compute-needs-complete-depends` | **11/15** | 49/57 OCA | 48–89% |
2543
+ | `assert-is-not-validation` | **3/15** | 6/10 OCA | 7–45% |
2544
+ | `sudo-bypasses-record-rules` | **0/15** | 6 true on OpenSPP | 0–20% |
2545
+
2546
+ **The finding is that precision depends on what kind of code is under review, and this
2547
+ is the first tree here that made that legible.** Two rules hold up on core. Two collapse,
2548
+ and both collapse for the same reason rather than because the checkers are broken.
2549
+
2550
+ `assert-is-not-validation`'s own true class, set when it was classified on OCA, is *the
2551
+ asserted value came from outside the module*. A framework's asserts are overwhelmingly
2552
+ invariants between its own components — the registry asserting its cache-name
2553
+ convention, a field descriptor asserting its own configuration, the query builder
2554
+ asserting an alias it generated itself. There is no outside. Twelve of the fifteen are
2555
+ that shape.
2556
+
2557
+ `sudo-bypasses-record-rules` is 0 of 15 and every one is a deliberate framework
2558
+ elevation: a public route resolving an access token before any identity exists, an
2559
+ `@api.ondelete` guard that has to see records the caller cannot or the guard is
2560
+ bypassable, a cross-company sync whose entire purpose is to cross companies. The rule
2561
+ describes an application leaking between tenants. Core is the thing that implements
2562
+ tenancy.
2563
+
2564
+ Neither is a reason to change a grade. A precision number is about a subject, and the
2565
+ subject here is one this corpus was never aimed at — nobody reviews odoo core with it.
2566
+ It is a reason to say so in the README, which is done, and it is the strongest argument
2567
+ yet that a single precision figure for "the corpus" means less than the same figure per
2568
+ kind of codebase.
2569
+
2570
+ **Three checker defects, all found by reading rather than by testing.**
2571
+
2572
+ `_auto = False` marks a model odoo builds no table for: a SQL view. There is no foreign
2573
+ key, so `ondelete` is not a decision anybody can make about it. Two of the fifteen
2574
+ ondelete findings were report models of exactly that kind, and the exemption joins
2575
+ TransientModel in `_transient_spans`. None of the 26 hand-verified true positives for
2576
+ this rule sits on such a model, so it is free.
2577
+
2578
+ The other two are both `sudo` de-escalation the checker read as escalation, the same
2579
+ class as the `sudo(False)` defect in §3.50 and missed by the same reading. Odoo's
2580
+ signature is `sudo(self, flag=True)`, so **`sudo(flag=False)`** is de-escalation by
2581
+ keyword and `_escalates` was only reading positional arguments. And **`x.sudo(self.env.su)`**
2582
+ sets su to whatever the environment already has, so it cannot raise privilege above
2583
+ ambient; core writes it beside the comment *"Unsudo the invoice after creation if not
2584
+ already sudoed"*. `sudo(self.env.su or field.compute_sudo)` is deliberately not exempt,
2585
+ because the `or` can turn it on.
2586
+
2587
+ Rates after all three: **4.7 per 1000 lines** on core, 0.8 on OpenSPP, 3.3 on OCA, with
2588
+ 244 of 244 hand-classified true positives still found.
2589
+
2590
+ **One finding worth acting on outside this project.** Core's
2591
+ `ir.model.access.group_names_with_access` interpolates its `access_mode` argument
2592
+ straight into SQL as `perm_{access_mode}`, and the only thing constraining it to four
2593
+ literals is an `assert`. The method is public and RPC-callable. Odoo does not run under
2594
+ `python -O`, so this is latent rather than live, and the assert also turns a bad
2595
+ argument into a 500 rather than a clean error — but it is exactly the harm the rule
2596
+ describes, found in the reference implementation.
2597
+
2598
+ **Still unread on core:** roughly 1,590 findings, led by `naive-and-aware-datetimes`
2599
+ (167), `constrains-must-list-every-field-read` (151) and
2600
+ `depends-must-not-name-unread-fields` (134).
2601
+
2602
  ## 4. Performance traps, all found by measurement
2603
 
2604
  Within four instrumented runs the report showed the 7B at ~10 tok/s against a
 
2672
 
2673
  - 1 of 27 rules has no automated check at all. It returns **no verdict**, never a
2674
  false pass, and `nexa coverage` prints the reason.
2675
+ - 12 of the 26 checked rules are decided by a **regex over generated code**. That
2676
  catches textual signatures, not meaning. Code wrong in a way no pattern describes
2677
+ passes silently. The other 14 are decided by an AST pass (§3.12, §3.47). The regexes
2678
+ no longer see docstrings, which are emptied before matching; they still see every
2679
+ other string literal, so a violation quoted inside a triple-quoted SQL string or a
2680
+ help text would still be reported.
2681
+ - The value trace behind four of those AST checkers (§3.47) reads **one file and one
2682
+ function**. It follows no import and infers no type. A float that arrives as a
2683
+ parameter is still reported as a float comparison; an integer returned by a helper
2684
+ in another module is too. A traversal or a money string that reaches `os.path.join`
2685
+ or `float()` through a helper in another file is not reported at all.
2686
  - Semantic analysis is **Python only**, which is the whole subject now. An answer may
2687
  still contain a JavaScript block, Odoo's web client being OWL; that block gets
2688
  delimiter balance and nothing deeper, and the layer reports UNKNOWN rather than
README.md CHANGED
@@ -130,10 +130,13 @@ publishing a classification.
130
  **Not a general linter.** 27 rules. It will not replace `pylint-odoo`, and after the
131
  wrapper above it does not have to.
132
 
133
- **Not a bug finder.** Against real Odoo bugfixes the ORM rules catch 7 of 162 — about
134
- 4%. Most bugs are domain logic ("finished plans should not be the current plan") and no rule
135
- corpus finds those. Measured and published here because a precision number without a
136
- recall number beside it is the more flattering half of the picture.
 
 
 
137
 
138
  **Odoo only, and that is the whole of it.** There was a TypeScript corpus and a
139
  JavaScript one. They got their first field evidence in September 2026 and it was not
@@ -219,33 +222,45 @@ which a regex cannot.
219
 
220
  ### What is wrong with the other half, named
221
 
222
- **Three rules fired and were never right.** `decimal-for-money-not-float` is 0 of 3, and
223
  this one has a *reason* behind it rather than a sample size: Odoo's `Monetary` field **is
224
  a float** — the ORM defines it that way — and the framework's answer to the precision
225
  problem is `float_round` and `float_compare` against the currency's rounding, not
226
- `Decimal`. Inside an Odoo module the rule fires on the idiom and names a fix that cannot
227
- be taken. That is an argument and not a measurement, and three findings settle nothing,
228
- so it is written down here rather than acted on.
229
-
230
- `path-join-does-not-contain-a-path` is 0 of 4 — all four join a configured directory to
231
- a name this module generated, and the pattern matches the *word* `filename` rather than
232
- anything about where the value came from. `eval-and-pickle-execute-their-input` is 0 of
233
- 1. Four findings between them is not enough to conclude a checker is wrong.
 
 
 
 
234
 
235
  **Three more never fired at all**, and silence is not a pass: it says 260,000 lines
236
  contain none of what they look for, which is also what a broken checker says. Two of the
237
  three are the two ruff already covers, which is the more likely explanation.
238
 
239
- **A checker defect, found by classifying rather than by testing.** Two of the 50 are the
240
- same fault: `scan_source` strips `#` comments before matching and does **not** strip
241
  string literals, so `eval()` named in a docstring arguing *against* using it, and a
242
- `datetime.now()` inside a doctest example, both read as code.
 
 
 
243
 
244
  **A suppression that should not have fired.** One finding — a consent-receipt timestamp
245
- — sits inside a `return` statement that carries a `# nosemgrep: odoo-sudo-without-context`
246
- marker four lines below it. Suppression reads any marker within the statement without
247
- asking which rule it names. That is the mute button this project says it will not build,
248
- reached from the other direction, and it is counted apart here rather than dropped.
 
 
 
 
 
249
 
250
  ---
251
 
@@ -528,8 +543,8 @@ The axes are separate because they answer different questions. `severity` is how
528
  bad it is if this happens; `likelihood` is how likely a finding is the bug, graded
529
  from what `nexa recall --aim` measured against real fix commits rather than from
530
  judgement. A rule can be severe and rarely the point — a Python uniqueness check
531
- really does race, and across 160 fixes not one of them was about that — and one
532
- field could not say so.
533
 
534
  Everything else is **derived** and must never be hand-edited:
535
 
@@ -547,13 +562,13 @@ Same principle as `z.infer<typeof Schema>`: one definition, derived artifacts, n
547
  |---|---|
548
  | Rules | 27 — 19 Odoo ORM, 8 plain-Python, one stack |
549
  | Critical | 12 |
550
- | Machine-checkable | 26 (96%) — 16 by pattern, 10 by AST |
551
  | Traceable to upstream docs | 25 (93%), each audited for topic fit |
552
  | Pattern false positives | 0 cross-hits across the corpus |
553
  | Recall under meaning-preserving rewrite | 79/79 |
554
- | Satisfied patterns bypassable by a comment | 0 of 7 |
555
  | Retrievable by their own probe | 27 of 27 at top 6, 25 of them at top 3 |
556
- | Hit rate on 33,960 lines of reviewed OCA code | 3.5 per 1000 lines |
557
  | Those 120 hits, classified by hand | **86 true positives**, 21 false, 13 undecidable from one file |
558
  | Hit rate on 226,937 lines of OpenSPP (Odoo 19) | 0.9 per 1000 lines, +69 another tool had already flagged, +71 advisory |
559
  | Every one of those findings, classified by hand | **157 true positives**, 137 false, 6 undecidable |
@@ -675,6 +690,18 @@ again the only thing that makes the rest of it safe.
675
  `classification-python-2026-09.json`. The rate went up and the precision went down, and
676
  that is the trade the section above prices.
677
 
 
 
 
 
 
 
 
 
 
 
 
 
678
  ### The same treatment on a second ecosystem
679
 
680
  A precision number from one ecosystem is a precision number for that ecosystem. The two
@@ -747,21 +774,51 @@ are the subject of
747
 
748
  ### What is still wrong, named
749
 
750
- `float-comparison-needs-float-utils` is next at **2 true, 5 false**. All five compare an
751
- *integer* to zero — `search_count`, a `read_group` `_count`, `SELECT COUNT(*)` — and the
752
- regex cannot see it because it matches on the variable's name, not its value. Fixing that
753
- means tracing the assignment, so it is written down here rather than quietly left out.
754
-
755
- `onchange-is-not-validation` is **4 true, 3 false**, and all three false ones are an
756
- onchange that sets a field rather than enforcing anything — which the rule's existing
757
- narrowing was supposed to exclude and does not catch when the write is to a Many2one.
758
-
759
- The composed-in Python half has four more, each with a number on it:
760
- `decimal-for-money-not-float` firing on Odoo's own `Monetary` idiom, `path-join`
761
- matching a variable's name rather than its origin, patterns matching inside docstrings,
762
- and a suppression marker silencing a rule it was not written about. All four are in
 
 
 
 
 
 
 
 
763
  [the section above](#what-is-wrong-with-the-other-half-named).
764
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
765
  What the first run found:
766
 
767
  - **`onchange-is-not-validation`** fired on all 16 `@api.onchange` declarations that
@@ -801,16 +858,22 @@ and again after. A finding that was there before, is gone after, **and sat on a
801
  line the fix actually changed** is the corpus noticing the bug that was really
802
  fixed.
803
 
804
- Across 385 fix commits in those two repositories, 56 of which touched a construct
805
  the corpus covers:
806
 
807
- | | |
808
- |---|---|
809
- | Caught | **1** |
810
- | Missed | 55 |
811
- | Rules that caught anything | 1 of 14 |
 
812
 
813
- That last condition, *on a line the fix changed*, is the entire measurement.
 
 
 
 
 
814
  Without it, any refactor that moves code counts as a catch. The first version of
815
  this tool did not have it and reported **12**. Reading the commit subjects killed
816
  the result: *"fix image url in locales with thousand separator"*, *"poor
@@ -820,18 +883,25 @@ because a dereference moved.
820
 
821
  **What this means, stated plainly.** Nexa is precise and narrow. It is right
822
  about what it describes, and it describes very little of what actually goes
823
- wrong. The 55 misses include bugs squarely inside its own stated territory:
824
-
825
- - *"remove sudo() when importing record"* — `sudo-bypasses-record-rules` exists
826
- - *"prevent removal of x2many values from inaccessible companies"* — a
827
- multi-company leak, which `check-company-on-relations` is about
828
- - *"log computed fields stored in db as expected"* — a stored compute
829
-
830
- **What this does not mean.** The denominator is generous. Many of those 56
831
- commits touched a covered construct while fixing something else entirely, so 2%
 
 
 
 
 
 
 
832
  understates recall against validation bugs specifically. Two repositories in one
833
- ecosystem is also a narrow sample. The number is a floor, and it is the first
834
- floor this project has had.
835
 
836
  The missed list is the more useful output. Every entry is a bug somebody
837
  actually fixed that the corpus had nothing to say about, which makes it a
@@ -869,32 +939,126 @@ clusters the fixes the corpus did not flag on the line of the fix by the constru
869
  each diff touched, so the reading order is the writing order. Against **OpenSPP 2
870
  (Odoo 19)** — a second ecosystem on a newer series than the OCA repositories:
871
 
872
- | | |
873
- |---|---|
874
- | Fix commits examined | 406 |
875
- | …touching a construct the corpus covers | 160 |
876
- | Caught | 6 |
877
- | Recall against in-scope fixes | 4% |
878
-
879
- Two repositories, two ecosystems, 2–4% both times. The floor is a floor — and the
880
- clustering says where to dig:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
881
 
882
  ```
883
- missed caught spoke silent
884
- .sudo( 70 6 47 23
885
- api.depends 23 0 16 7
886
- api.constrains 16 0 12 4
887
- ondelete 13 0 10 3
 
 
888
  ```
889
 
890
  **`spoke` is the column that changed the reading.** A miss is not automatically a
891
  blind spot: a checker firing somewhere else in the same file means the rule exists
892
- and is aimed wrong, which wants a sharper rule rather than a new one. Ninety-two of
893
- the 154 misses are that. The first version of this table reported `21x api.depends
894
- (nothing caught here)`, which reads as a missing rule — and the checker was in fact
895
- firing on sixteen of those files while the fixes went elsewhere.
896
-
897
- Reading the diffs behind the seven that really were silent produced one rule,
 
 
 
 
 
 
898
  `odoo.depends-must-not-name-unread-fields`. Two of the remaining rules in this corpus
899
  came from the crosswalk below.
900
 
 
130
  **Not a general linter.** 27 rules. It will not replace `pylint-odoo`, and after the
131
  wrapper above it does not have to.
132
 
133
+ **Not a bug finder.** Against real Odoo bugfixes the corpus catches 20 of 334 — about
134
+ **6%**, measured across **three** ecosystems and 1,164 fix commits: OpenSPP 6%, the two
135
+ OCA repositories 6%, and odoo core itself 5%. Most bugs are domain logic ("finished
136
+ plans should not be the current plan") and no rule corpus finds those. Measured and
137
+ published here because a precision number without a recall number beside it is the more
138
+ flattering half of the picture. It was 2–4% on two trees before the pass in §3.47; the
139
+ way it moved was reading the missed list, not writing more rules.
140
 
141
  **Odoo only, and that is the whole of it.** There was a TypeScript corpus and a
142
  JavaScript one. They got their first field evidence in September 2026 and it was not
 
222
 
223
  ### What is wrong with the other half, named
224
 
225
+ **Three rules fired and were never right.** `decimal-for-money-not-float` was 0 of 3, and
226
  this one has a *reason* behind it rather than a sample size: Odoo's `Monetary` field **is
227
  a float** — the ORM defines it that way — and the framework's answer to the precision
228
  problem is `float_round` and `float_compare` against the currency's rounding, not
229
+ `Decimal`. Inside an Odoo module the rule fired on the idiom and named a fix that cannot
230
+ be taken. Three findings settle nothing, so it was narrowed rather than culled, on the
231
+ line the argument itself draws: it now fires only where the value is still a string
232
+ when it arrives, which is the one place `Decimal(value)` is available. `float(self.amount)`
233
+ is the ORM's own type and is not reported. Re-run on both trees: **0 findings**, from 3.
234
+
235
+ `path-join-does-not-contain-a-path` was 0 of 4 — all four join a configured directory to
236
+ a name this module generated, and the pattern matched the *word* `filename` rather than
237
+ anything about where the value came from. It now asks where the value came from instead,
238
+ and reports only a component that arrived from outside the file. Re-run: **0 findings**,
239
+ from 4. That is less than the regex reported, on purpose; everything the regex reported
240
+ was false. `eval-and-pickle-execute-their-input` is 0 of 1, and that one is below.
241
 
242
  **Three more never fired at all**, and silence is not a pass: it says 260,000 lines
243
  contain none of what they look for, which is also what a broken checker says. Two of the
244
  three are the two ruff already covers, which is the more likely explanation.
245
 
246
+ **A checker defect, found by classifying rather than by testing.** Two of the 50 were the
247
+ same fault: `scan_source` stripped `#` comments before matching and did **not** strip
248
  string literals, so `eval()` named in a docstring arguing *against* using it, and a
249
+ `datetime.now()` inside a doctest example, both read as code. Docstrings are now emptied
250
+ before any pattern sees the file — docstrings only, since a triple-quoted SQL query is
251
+ data the checkers have a legitimate interest in — with the quotes, the newlines and the
252
+ length left in place so no line or column moves. Both findings are gone on the re-run.
253
 
254
  **A suppression that should not have fired.** One finding — a consent-receipt timestamp
255
+ — sat inside a `return` statement carrying a `# nosemgrep: odoo-sudo-without-context`
256
+ marker four lines below it. Suppression read any marker within the statement without
257
+ asking which rule it named. That is the mute button this project says it will not build,
258
+ reached from the other direction. A marker that *says* what it is about — a code with
259
+ words in it — now covers only rules that share one of those words, so a marker about
260
+ sudo no longer speaks for a finding about datetimes, while the 141 OpenSPP sudo sites
261
+ carrying that same marker still read as decided. A code with no words in it, `B608` or
262
+ `E501`, cannot be compared with anything and covers its line as before; that gap is
263
+ named in `layers._marker_is_about` rather than papered over.
264
 
265
  ---
266
 
 
543
  bad it is if this happens; `likelihood` is how likely a finding is the bug, graded
544
  from what `nexa recall --aim` measured against real fix commits rather than from
545
  judgement. A rule can be severe and rarely the point — a Python uniqueness check
546
+ really does race, and across 237 in-scope fixes in two ecosystems not one of them
547
+ was about that — and one field could not say so.
548
 
549
  Everything else is **derived** and must never be hand-edited:
550
 
 
562
  |---|---|
563
  | Rules | 27 — 19 Odoo ORM, 8 plain-Python, one stack |
564
  | Critical | 12 |
565
+ | Machine-checkable | 26 (96%) — 12 by pattern, 14 by AST |
566
  | Traceable to upstream docs | 25 (93%), each audited for topic fit |
567
  | Pattern false positives | 0 cross-hits across the corpus |
568
  | Recall under meaning-preserving rewrite | 79/79 |
569
+ | Satisfied patterns bypassable by a comment | 0 of 3 |
570
  | Retrievable by their own probe | 27 of 27 at top 6, 25 of them at top 3 |
571
+ | Hit rate on 33,960 lines of reviewed OCA code | 3.3 per 1000 lines |
572
  | Those 120 hits, classified by hand | **86 true positives**, 21 false, 13 undecidable from one file |
573
  | Hit rate on 226,937 lines of OpenSPP (Odoo 19) | 0.9 per 1000 lines, +69 another tool had already flagged, +71 advisory |
574
  | Every one of those findings, classified by hand | **157 true positives**, 137 false, 6 undecidable |
 
690
  `classification-python-2026-09.json`. The rate went up and the precision went down, and
691
  that is the trade the section above prices.
692
 
693
+ **Re-measured again on 2026-09-21, after the six false-positive causes named above were
694
+ closed: 112 hits, 3.3 per 1000 lines.** Four known false positives are gone — three
695
+ `path-join` and one `decimal-for-money` — and three new `float-comparison` findings
696
+ arrived, each read by hand, from a `satisfied_pattern` that had been clearing whole
697
+ files on a `float_compare` written somewhere else in them.
698
+
699
+ The check that makes all of this safe is the same one every time, and it is worth
700
+ stating as a number rather than a promise: across all five classification artifacts,
701
+ **244 hand-classified true positives, 244 still found, none lost.** That is asserted by
702
+ re-running the checkers against the same two checkouts and matching every `true` verdict
703
+ back to a live finding, not by reading the diff and hoping.
704
+
705
  ### The same treatment on a second ecosystem
706
 
707
  A precision number from one ecosystem is a precision number for that ecosystem. The two
 
774
 
775
  ### What is still wrong, named
776
 
777
+ `float-comparison-needs-float-utils` was **2 true, 5 false**. All five compared an
778
+ *integer* to zero — `search_count`, a `read_group` `_count`, `SELECT COUNT(*)`, a
779
+ `fields.Integer` — and the regex could not see it because it matched the variable's
780
+ name, not its value. Three of the five and both true positives were spelled `total == 0`.
781
+ The checker now follows the assignment, and the five are gone. It also stopped clearing
782
+ the whole file on a `float_compare` anywhere in it, which the old `satisfied_pattern`
783
+ did: on OCA that surfaces three `Monetary` and `Float` fields compared with `!= 0` in
784
+ files that use `float_compare` elsewhere, each read by hand. **OpenSPP 7 → 2, OCA 1 → 3.**
785
+
786
+ `onchange-is-not-validation` was **4 true, 3 false**, and the three were not what they
787
+ looked like. Each is a four-line onchange that assigns a field, followed by an action
788
+ method that raises — and the regex read 400 characters past the decorator with no way to
789
+ know where the method ended. `_onchange_variable_id` sets a target model; the
790
+ `UserError` 200 characters below it belongs to `action_remap`. Scoped to the decorated
791
+ function, the three are gone. Three new ones arrived in their place and were read: a
792
+ phone-number validation living only in an onchange with no constraint in the file, a
793
+ `_check_date_collected` onchange raising `exceptions.ValidationError` — a spelling the
794
+ regex's `\w*` never matched — and one that clears a field and returns the warning dict.
795
+ Count unchanged at 7; set changed entirely.
796
+
797
+ The composed-in Python half's four are fixed and measured in
798
  [the section above](#what-is-wrong-with-the-other-half-named).
799
 
800
+ `sudo-bypasses-record-rules` now checks four clauses rather than one: a **query** under
801
+ sudo (search, search_count, search_read, read_group), a **touch** (create, write,
802
+ unlink, copy, read), a sudo recordset bound to a name and **passed on**, and a name
803
+ **rebound** to its own sudo form. The query clause is the one that was always checked
804
+ and it keeps its 6 hand-classified true positives.
805
+
806
+ Every live finding the new clauses made across both trees was read by hand — **0 true,
807
+ 24 false**, in `classification-sudo-2026-09.json`. Each is code where the caller is an
808
+ administrator by construction, a demo-data seeder or a wizard behind
809
+ `group_account_manager`, or a framework model with no tenant boundary at all. So the
810
+ touch and escape clauses report at likelihood `unproven`, which makes their findings
811
+ advisory: shown, counted apart, gating nothing. The grade is carried by the finding and
812
+ not by the rule, so the query clause keeps `defect`. A clause the rule's own sentence
813
+ asks for is worth having under measurement; a clause measured at zero is not worth
814
+ failing a build on.
815
+
816
+ The rebind clause is the exception and keeps `defect`, because it has the one thing the
817
+ others lack: an incident. It turns the miss this README names by hand —
818
+ *"remove sudo() when importing record"* — into an on-target catch, which is the same
819
+ evidence every other `defect` grade in the corpus rests on. It also produces one false
820
+ positive at HEAD, and both facts are in the artifact.
821
+
822
  What the first run found:
823
 
824
  - **`onchange-is-not-validation`** fired on all 16 `@api.onchange` declarations that
 
858
  line the fix actually changed** is the corpus noticing the bug that was really
859
  fixed.
860
 
861
+ Across 386 fix commits in those two repositories, 64 of which touched a construct
862
  the corpus covers:
863
 
864
+ | | 2026-09-16 | 2026-09-21 |
865
+ |---|---|---|
866
+ | Caught | 1 | **4** |
867
+ | Missed | 55 | 60 |
868
+ | Recall against in-scope fixes | 2% | **6%** |
869
+ | Rules that caught anything | 1 of 14 | **3 of 27** |
870
 
871
+ The right-hand column is after the pass in §3.47 of ARCHITECTURE. Three of the
872
+ four catches are `sudo-bypasses-record-rules`, which grew the two clauses its own
873
+ sentence always asked for; the in-scope denominator went up as well, from 56 to
874
+ 64, because the corpus now covers more constructs than it did.
875
+
876
+ That condition, *on a line the fix changed*, is the entire measurement.
877
  Without it, any refactor that moves code counts as a catch. The first version of
878
  this tool did not have it and reported **12**. Reading the commit subjects killed
879
  the result: *"fix image url in locales with thousand separator"*, *"poor
 
883
 
884
  **What this means, stated plainly.** Nexa is precise and narrow. It is right
885
  about what it describes, and it describes very little of what actually goes
886
+ wrong. Three bugs squarely inside its own stated territory were named here as
887
+ misses, and one of them is now a catch:
888
+
889
+ - *"remove sudo() when importing record"* — **caught, 2026-09-21.** The line the
890
+ fix deletes is `self = self.sudo()`, which rebinds the method's own receiver so
891
+ that everything below it runs elevated without naming sudo again. Every clause
892
+ looking for `.sudo().method()` was blind to it. The commit says what it cost:
893
+ *"finding multiple records across all companies during import when they have
894
+ same record name."*
895
+ - *"prevent removal of x2many values from inaccessible companies"* — still
896
+ missed. A multi-company leak, which `check-company-on-relations` is about.
897
+ - *"log computed fields stored in db as expected"* — still missed. A stored
898
+ compute.
899
+
900
+ **What this does not mean.** The denominator is generous. Many of those 64
901
+ commits touched a covered construct while fixing something else entirely, so 6%
902
  understates recall against validation bugs specifically. Two repositories in one
903
+ ecosystem is also a narrow sample. The number is still a floor; it is a higher
904
+ one than it was.
905
 
906
  The missed list is the more useful output. Every entry is a bug somebody
907
  actually fixed that the corpus had nothing to say about, which makes it a
 
939
  each diff touched, so the reading order is the writing order. Against **OpenSPP 2
940
  (Odoo 19)** — a second ecosystem on a newer series than the OCA repositories:
941
 
942
+ | | 2026-09-16 | 2026-09-21 |
943
+ |---|---|---|
944
+ | Fix commits examined | 406 | 409 |
945
+ | …touching a construct the corpus covers | 160 | 173 |
946
+ | Caught | 6 | **11** |
947
+ | Recall against in-scope fixes | 4% | **6%** |
948
+
949
+ **A third ecosystem, and it agrees.** The criticism this section used to carry was that
950
+ two repositories in one ecosystem is a narrow sample. So the same pass was run against
951
+ **odoo core 18.0** — 559,663 lines, 190,000 commits — capped at 400 fix commits:
952
+
953
+ | | OpenSPP | OCA | odoo core | all three |
954
+ |---|---|---|---|---|
955
+ | Fix commits examined | 409 | 386 | 369 | **1,164** |
956
+ | …touching a covered construct | 173 | 64 | 97 | **334** |
957
+ | Caught | 11 | 4 | 5 | **20** |
958
+ | Recall against in-scope fixes | 6% | 6% | 5% | **6%** |
959
+
960
+ Three ecosystems arriving at the same number from different directions is worth more
961
+ than any one of them. It was 2% and 4% on two trees before this pass. The floor is
962
+ still a floor; it is a better-founded one.
963
+
964
+ Precision on core is higher than on either other tree: **2,663 findings at 4.8 per 1000
965
+ lines** after the narrowing below. Odoo core is the reference implementation, so a rate
966
+ there is a statement about the rules and not about somebody's house style.
967
+
968
+ **The first classification here that is a sample rather than a census.** Reading all
969
+ 2,784 findings is not a session's work, and 2,784 is twenty-five times any set read here
970
+ before. So 15 findings of the largest rule — `stored-compute-needs-complete-depends`,
971
+ 1,051 of the total — were drawn with a fixed seed and read by hand:
972
+ **11 true, 4 false**, in `classification-odoo-core-2026-09.json`. That is 73%, with a
973
+ 95% Wilson interval of roughly 48% to 89%. The interval is wide, and it is the honest
974
+ number for fifteen: it says the rule is not mostly noise on core and not much more. The
975
+ draw reproduces from the artifact's own description, without the script that made it.
976
+
977
+ All four false verdicts have one cause. `odoo/models.py` gives every model
978
+ `display_name = fields.Char(automatic=True, compute='_compute_display_name', ...)` with
979
+ **no store**, so an override that omits a dependency cannot leave a stale row: there is
980
+ no row. The checker read "not declared in this file" as unknown and kept the finding,
981
+ which is right for a field declared in another module and wrong for one the framework
982
+ declares itself. **121 of that rule's 1,051 findings on core were this shape.** The
983
+ narrowing is free and that was checked, not assumed: all ten hand-verified true
984
+ positives that compute `display_name` declare `store=True` on it in their own file, so
985
+ they are untouched.
986
+
987
+ The larger thing the sample turned up was deliberately **not** acted on. 837 of core's
988
+ 1,360 stored computes are `readonly=False` — the "compute a smart default, then let the
989
+ user own it" pattern, where a narrow `@api.depends` is often protecting a user's edit
990
+ rather than forgetting a dependency. That would explain most of this rule's volume on
991
+ core, and it is not a safe narrowing: three of the 47 resolvable hand-verified true
992
+ positives are themselves `readonly=False`, and so are nine of the eleven true verdicts
993
+ in this sample. The number big enough to justify the rule is the number that refutes it.
994
+
995
+ The next three rules by volume were read the same way, 15 each, so 60 of core's findings
996
+ are now classified across the four rules that make 70% of them:
997
+
998
+ | rule | core | previously measured |
999
+ |---|---|---|
1000
+ | `ondelete-is-a-decision` | **13/15** | 12/12 OCA, 14/14 OpenSPP |
1001
+ | `stored-compute-needs-complete-depends` | **11/15** | 49/57 OCA |
1002
+ | `assert-is-not-validation` | **3/15** | 6/10 OCA |
1003
+ | `sudo-bypasses-record-rules` | **0/15** | 6 true on OpenSPP |
1004
+
1005
+ **Precision turns out to depend on what kind of code is under review**, and this is the
1006
+ first tree here that made that legible. Two rules hold up. Two collapse, and not because
1007
+ the checkers broke. `assert-is-not-validation`'s own true class is *the asserted value
1008
+ came from outside the module*, and a framework's asserts are invariants between its own
1009
+ components — the registry, the field descriptors, the query builder — where there is no
1010
+ outside. `sudo-bypasses-record-rules` describes an application leaking between tenants,
1011
+ and core is the thing that implements tenancy: every finding is a public route resolving
1012
+ a token before identity exists, a delete guard that must see what the caller cannot, or
1013
+ a cross-company sync whose whole purpose is to cross companies.
1014
+
1015
+ Neither moves a grade. A precision number is about a subject, and nobody reviews odoo
1016
+ core with this. It is the strongest argument so far that one precision figure for "the
1017
+ corpus" says less than the same figure per kind of codebase.
1018
+
1019
+ Reading them turned up three more checker defects, all of the same family as the
1020
+ `sudo(False)` one above: a model with `_auto = False` is a SQL view with no foreign key,
1021
+ so `ondelete` is not a decision about it; `sudo(flag=False)` is de-escalation by
1022
+ keyword; and `x.sudo(self.env.su)` restores the ambient level and cannot raise
1023
+ privilege. Core is now **2,650 findings at 4.7 per 1000 lines**.
1024
+
1025
+ One finding is worth acting on outside this project. Core's
1026
+ `ir.model.access.group_names_with_access` interpolates its `access_mode` argument
1027
+ straight into SQL as `perm_{access_mode}`, and the only thing constraining it to four
1028
+ literals is an `assert`. The method is public and RPC-callable. Odoo does not run under
1029
+ `python -O`, so it is latent rather than live — but it is exactly the harm the rule
1030
+ describes, in the reference implementation.
1031
+
1032
+ Still unread: roughly 1,590 findings on core, led by `naive-and-aware-datetimes` (167),
1033
+ `constrains-must-list-every-field-read` (151) and `depends-must-not-name-unread-fields`
1034
+ (134).
1035
+
1036
+ Where the OpenSPP clustering stands now:
1037
 
1038
  ```
1039
+ missed caught spoke silent
1040
+ .sudo( 67 9 52 15
1041
+ ValidationError 40 2 27 13
1042
+ Many2one 26 1 14 12
1043
+ api.depends 23 0 14 9
1044
+ noupdate 22 0 9 13
1045
+ api.constrains 16 0 10 6
1046
  ```
1047
 
1048
  **`spoke` is the column that changed the reading.** A miss is not automatically a
1049
  blind spot: a checker firing somewhere else in the same file means the rule exists
1050
+ and is aimed wrong, which wants a sharper rule rather than a new one. Ninety-seven
1051
+ of the 162 misses are that. The first version of this table reported `21x
1052
+ api.depends (nothing caught here)`, which reads as a missing rule — and the checker
1053
+ was in fact firing on sixteen of those files while the fixes went elsewhere.
1054
+
1055
+ `.sudo(` stayed the largest cluster and was worked twice. Its silent column fell
1056
+ from 23 to 15 and its caught column rose from 6 to 9, because the rule grew the two
1057
+ clauses its own sentence asked for and then a third, `self = self.sudo()`, taken
1058
+ from a miss in the OCA list. `noupdate` is now the cluster with the most silence
1059
+ against the least coverage, and it is next.
1060
+
1061
+ Reading the diffs behind the seven that were silent produced one rule,
1062
  `odoo.depends-must-not-name-unread-fields`. Two of the remaining rules in this corpus
1063
  came from the crosswalk below.
1064
 
bin/context.py CHANGED
@@ -49,6 +49,13 @@ _RULE_DOMAIN_RE = re.compile(
49
  # real filter, because deciding that a domain is vacuous in general means
50
  # evaluating it.
51
  _VACUOUS_DOMAIN = re.compile(r"^\[\s*\(\s*1\s*,\s*[\"']=[\"']\s*,\s*1\s*\)\s*\]$")
 
 
 
 
 
 
 
52
  _NAME_RE = re.compile(r"[\"']name[\"']\s*:\s*[\"']([^\"']+)[\"']")
53
  _MODEL_RE = re.compile(r"^\s*_(?:name|inherit)\s*=\s*[\"']([a-z0-9_.]+)[\"']", re.M)
54
  _ODOO_IMPORT_RE = re.compile(r"^\s*from\s+odoo[\w.]*\s+import\s+(.+)$", re.M)
@@ -263,7 +270,7 @@ def tree_facts(paths, complete=True):
263
 
264
  declared, deleted = set(), set()
265
  unlink_tokens = set()
266
- ruled = set()
267
  multi_company = False
268
  seen = 0
269
  # Every series the manifests in this walk name. Kept as a set because
@@ -297,7 +304,8 @@ def tree_facts(paths, complete=True):
297
  semantics.MULTI_COMPANY_MARKERS):
298
  multi_company = True
299
  if "ir.rule" in body:
300
- ruled |= _models_with_a_filtering_rule(body)
 
301
 
302
  # Access tokens are matched FORWARDS, from each declared model to the token
303
  # it would have. Parsing `model_sale_order_line` back into a dotted name is
@@ -312,6 +320,7 @@ def tree_facts(paths, complete=True):
312
  return {"walked": True, "complete": complete, "files": seen,
313
  "models_declared": declared, "models_deleted": deleted,
314
  "models_record_ruled": ruled,
 
315
  "multi_company": multi_company,
316
  "odoo_series": pinned.pop() if len(pinned) == 1 else None}
317
 
@@ -342,7 +351,7 @@ def _models_with_a_filtering_rule(body):
342
  pattern silently lost them; and a model whose rules are all unreadable is a
343
  model nobody can show is protected, which is a finding worth making anyway.
344
  """
345
- found = set()
346
  for block in _IR_RULE_RE.findall(body):
347
  ref = _RULE_MODEL_RE.search(block)
348
  if not ref:
@@ -354,7 +363,15 @@ def _models_with_a_filtering_rule(body):
354
  text = " ".join(domain.group(1).split()) if domain else ""
355
  if text and _VACUOUS_DOMAIN.match(text):
356
  continue
357
- found.add(token[len("model_"):].replace("_", "."))
 
 
 
 
 
 
 
 
358
  return found
359
 
360
 
@@ -390,6 +407,28 @@ def model_is_record_ruled(facts, model):
390
  return ABSENT if model in facts.get("models_declared", ()) else UNKNOWN
391
 
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  def _models_granted_unlink(body):
394
  """The `model_id:id` tokens an ir.model.access.csv grants perm_unlink on."""
395
  rows = list(csv.reader(io.StringIO(body)))
 
49
  # real filter, because deciding that a domain is vacuous in general means
50
  # evaluating it.
51
  _VACUOUS_DOMAIN = re.compile(r"^\[\s*\(\s*1\s*,\s*[\"']=[\"']\s*,\s*1\s*\)\s*\]$")
52
+
53
+ # The left operand of every leaf in a domain: the fields the rule filters ON.
54
+ # A domain is a flat list of triples once the operators are ignored, and the
55
+ # first element of each is a field path. Read for one purpose only -- deciding
56
+ # whether a call under sudo names the field the rule it bypasses is about --
57
+ # and a field this fails to read simply is not offered as an exemption.
58
+ _RULE_FIELD_RE = re.compile(r"[\(\[]\s*[\"']([a-z_][a-z0-9_.]*)[\"']\s*,")
59
  _NAME_RE = re.compile(r"[\"']name[\"']\s*:\s*[\"']([^\"']+)[\"']")
60
  _MODEL_RE = re.compile(r"^\s*_(?:name|inherit)\s*=\s*[\"']([a-z0-9_.]+)[\"']", re.M)
61
  _ODOO_IMPORT_RE = re.compile(r"^\s*from\s+odoo[\w.]*\s+import\s+(.+)$", re.M)
 
270
 
271
  declared, deleted = set(), set()
272
  unlink_tokens = set()
273
+ ruled = {}
274
  multi_company = False
275
  seen = 0
276
  # Every series the manifests in this walk name. Kept as a set because
 
304
  semantics.MULTI_COMPANY_MARKERS):
305
  multi_company = True
306
  if "ir.rule" in body:
307
+ for model, fields in _models_with_a_filtering_rule(body).items():
308
+ ruled.setdefault(model, set()).update(fields)
309
 
310
  # Access tokens are matched FORWARDS, from each declared model to the token
311
  # it would have. Parsing `model_sale_order_line` back into a dotted name is
 
320
  return {"walked": True, "complete": complete, "files": seen,
321
  "models_declared": declared, "models_deleted": deleted,
322
  "models_record_ruled": ruled,
323
+ "models_rule_fields": {m: tuple(sorted(f)) for m, f in ruled.items()},
324
  "multi_company": multi_company,
325
  "odoo_series": pinned.pop() if len(pinned) == 1 else None}
326
 
 
351
  pattern silently lost them; and a model whose rules are all unreadable is a
352
  model nobody can show is protected, which is a finding worth making anyway.
353
  """
354
+ found = {}
355
  for block in _IR_RULE_RE.findall(body):
356
  ref = _RULE_MODEL_RE.search(block)
357
  if not ref:
 
363
  text = " ".join(domain.group(1).split()) if domain else ""
364
  if text and _VACUOUS_DOMAIN.match(text):
365
  continue
366
+ model = token[len("model_"):].replace("_", ".")
367
+ fields = found.setdefault(model, set())
368
+ for path in _RULE_FIELD_RE.findall(text):
369
+ # Both the path and the relation it starts from. A rule reading
370
+ # `case_id.case_worker_id` is satisfied by a create that names
371
+ # `case_id`, because the case it points at is one the caller
372
+ # can already see and the rule follows the link to find out.
373
+ fields.add(path)
374
+ fields.add(path.split(".", 1)[0])
375
  return found
376
 
377
 
 
407
  return ABSENT if model in facts.get("models_declared", ()) else UNKNOWN
408
 
409
 
410
+ def model_rule_fields(facts, model):
411
+ """The fields this model's own filtering rules are written about.
412
+
413
+ `[('case_worker_id', '=', user.id)]` yields `case_worker_id`, and
414
+ `[('case_id.case_worker_id', '=', user.id)]` yields both the path and
415
+ `case_id`, the relation it is reached through.
416
+
417
+ This exists so a call under sudo can be compared against the rule it
418
+ would actually bypass, instead of against the word `company_id`. A
419
+ per-user rule is as real as a multi-company one -- OpenSPP's case models
420
+ are governed entirely by `case_worker_id` and `case_id.case_worker_id`,
421
+ and not one of them has a company rule at all -- so a checker that knows
422
+ only the multi-company spelling cannot see when the advice was followed.
423
+
424
+ Empty for a model nobody declared a readable rule for, which offers no
425
+ exemption and leaves the finding standing.
426
+ """
427
+ if not facts or not facts.get("walked") or not model:
428
+ return ()
429
+ return tuple(facts.get("models_rule_fields", {}).get(model, ()))
430
+
431
+
432
  def _models_granted_unlink(body):
433
  """The `model_id:id` tokens an ir.model.access.csv grants perm_unlink on."""
434
  rows = list(csv.reader(io.StringIO(body)))
bin/layers.py CHANGED
@@ -246,6 +246,14 @@ def check_compliance(text, rules):
246
  is exactly what the 7B did. This checks the generated code for each rule's
247
  violation signature instead.
248
 
 
 
 
 
 
 
 
 
249
  Kept as a module-level function with the original signature because pipeline
250
  and corpus both call it directly; the domain layer wraps it.
251
  """
@@ -255,13 +263,25 @@ def check_compliance(text, rules):
255
  views = {}
256
  violations, checked = [], []
257
  for r in rules:
258
- if not r.get("violation_pattern"):
 
259
  continue
260
  checked.append(r["id"])
261
  kind = comment_kind(r.get("stack"))
262
  if kind not in views:
263
  views[kind] = semantics.strip_comments(code, kind)
264
- if fires(r, views[kind], stripped=True):
 
 
 
 
 
 
 
 
 
 
 
265
  violations.append((r["id"], r["severity"], r["title"]))
266
  return violations, checked
267
 
@@ -276,6 +296,98 @@ SUPPRESSION_MARKERS = ("noqa", "nosemgrep", "nosec", "pylint: disable",
276
  "pylint:disable", "type: ignore", "eslint-disable",
277
  "@ts-ignore", "@ts-expect-error")
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  # Statement kinds a marker is allowed to reach across. Every one of these is a
280
  # SIMPLE statement: it has no body of its own, so its line span is the span of
281
  # one expression and nothing else can hide inside it. Compound statements are
@@ -285,8 +397,28 @@ _SIMPLE_STMTS = (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Expr,
285
  ast.Return, ast.Raise, ast.Assert, ast.Delete)
286
 
287
 
 
 
 
 
 
 
 
 
 
 
 
288
  def _simple_statement_spans(body):
289
- """(start, end) for every simple statement in a Python source, widest last.
 
 
 
 
 
 
 
 
 
290
 
291
  A finding anchors on one line, and a suppression marker written about that
292
  finding may sit on another line of the SAME expression. Measured on
@@ -319,7 +451,7 @@ def _simple_statement_spans(body):
319
  # 36 of OpenSPP's 105 sit there, above a statement that fits on
320
  # one line and has no room for anything else. Reading the same
321
  # convention the marker was written under is not obeying it.
322
- spans.append((max(1, start - 1), end))
323
  return spans
324
 
325
 
@@ -405,24 +537,33 @@ def scan_source(body, rules, stack, errors=None, facts=None, gaps=None):
405
  # call will read as unsuppressed when a person had in fact marked it.
406
  spans = _simple_statement_spans(body) if comment_kind(stack) == "python" else []
407
 
408
- def _has_marker(text):
409
- return any(marker in text for marker in SUPPRESSION_MARKERS)
 
410
 
411
- def marked(line):
412
  text = rows[line - 1] if 0 < line <= len(rows) else ""
413
- if _has_marker(text):
414
  return True
415
  # The smallest simple statement containing this line, if any: a marker
416
  # written inside one expression covers that expression and stops there.
 
 
 
 
 
417
  best = None
418
- for start, end in spans:
419
- if start <= line <= end and (best is None or
420
- (end - start) < (best[1] - best[0])):
421
- best = (start, end)
 
 
422
  if best is None:
423
  return False
424
- return any(_has_marker(rows[n - 1]) for n in range(best[0], best[1] + 1)
425
- if 0 < n <= len(rows))
 
426
 
427
  # One parse for the whole file, before any semantic checker runs, so that
428
  # "nothing to say" and "could not read it" stop being the same output. Only
@@ -463,7 +604,7 @@ def scan_source(body, rules, stack, errors=None, facts=None, gaps=None):
463
  continue
464
  seen.add(line)
465
  out.append(SourceHit(rule["id"], rule["severity"], line,
466
- line_at(body, line), marked(line),
467
  bool(rule.get("advisory")),
468
  rule.get("likelihood", "unmeasured")))
469
  elif rule.get("semantic_check"):
@@ -479,8 +620,19 @@ def scan_source(body, rules, stack, errors=None, facts=None, gaps=None):
479
  for line, message, grade in found:
480
  # A grade means a trigger fired on THIS finding and earned it a
481
  # likelihood of its own. Absent, the rule's grade stands.
 
 
 
 
 
 
 
 
 
482
  out.append(SourceHit(rule["id"], rule["severity"], line, message,
483
- marked(line), bool(rule.get("advisory")),
 
 
484
  grade or rule.get("likelihood",
485
  "unmeasured")))
486
  if unparsed is not None and gaps is not None:
 
246
  is exactly what the 7B did. This checks the generated code for each rule's
247
  violation signature instead.
248
 
249
+ Semantic checkers count here too. They did not, and the omission was
250
+ invisible because it looked like coverage: a rule whose checker moved from
251
+ a regex to an AST pass silently stopped being checked against generated
252
+ code, and this function's own motivating example -- a model citing
253
+ 'onchange is not validation' and then writing an onchange -- is one of
254
+ the rules that moved. A checker that cannot see the answer is not a
255
+ stricter checker.
256
+
257
  Kept as a module-level function with the original signature because pipeline
258
  and corpus both call it directly; the domain layer wraps it.
259
  """
 
263
  views = {}
264
  violations, checked = [], []
265
  for r in rules:
266
+ pattern, check = r.get("violation_pattern"), r.get("semantic_check")
267
+ if not pattern and not check:
268
  continue
269
  checked.append(r["id"])
270
  kind = comment_kind(r.get("stack"))
271
  if kind not in views:
272
  views[kind] = semantics.strip_comments(code, kind)
273
+ hit = False
274
+ if check:
275
+ # An answer whose code does not parse is a syntactic finding and
276
+ # is reported as one; here it simply produces no semantic hit,
277
+ # exactly as a pattern that does not match produces none.
278
+ try:
279
+ hit = bool(semantics.run_check(check, code))
280
+ except (SyntaxError, ValueError, RecursionError):
281
+ hit = False
282
+ if not hit and pattern:
283
+ hit = fires(r, views[kind], stripped=True)
284
+ if hit:
285
  violations.append((r["id"], r["severity"], r["title"]))
286
  return violations, checked
287
 
 
296
  "pylint:disable", "type: ignore", "eslint-disable",
297
  "@ts-ignore", "@ts-expect-error")
298
 
299
+ # What a marker names, if it names anything: `# noqa: E501`,
300
+ # `# nosemgrep: odoo-sudo-without-context`, `# pylint: disable=no-member`,
301
+ # `# type: ignore[arg-type]`, `# nosec B608`. A marker with nothing after it
302
+ # is bare and is about whatever was on that line.
303
+ _MARKER_CODES = re.compile(
304
+ r"(?:" + "|".join(re.escape(m) for m in SUPPRESSION_MARKERS) + r")"
305
+ r"\s*[:=\[]?\s*([A-Za-z][A-Za-z0-9_.\-]*(?:\s*[,\s]\s*[A-Za-z][A-Za-z0-9_.\-]*)*)?")
306
+
307
+ # A code that SAYS what it is about: two or more parts joined by -, _ or .,
308
+ # with a real word among them. `odoo-sudo-without-context` and `no-member`
309
+ # qualify; `B608`, `E501` and `W0212` do not, and neither does the prose
310
+ # people write after a colon -- `# noqa: decided, TICKET-9`. Only a code in
311
+ # the first group can be compared with a rule id at all, so only that group
312
+ # is narrowed; everything else keeps the behaviour it had.
313
+ _SPEAKING_CODE = re.compile(r"^(?=[^A-Za-z]*[A-Za-z]{3})"
314
+ r"[A-Za-z0-9]+(?:[-_.][A-Za-z0-9]+)+$")
315
+
316
+ # Words that identify a vendor or a language rather than a defect. A code and
317
+ # a rule id sharing only these has not said they are about the same thing.
318
+ _UNDISTINGUISHING = frozenset([
319
+ "odoo", "python", "py", "js", "ts", "oca", "openspp", "rule", "rules",
320
+ "the", "a", "an", "and", "or", "of", "in", "on", "to", "for", "is", "are",
321
+ "be", "not", "no", "it", "its", "this", "that", "with", "without", "do",
322
+ "does", "must", "should", "needs", "need", "use", "used", "using", "by",
323
+ "from", "at", "as", "all", "any", "every", "one", "two", "disable",
324
+ "ignore", "expect", "error", "warning", "check", "checks", "bad",
325
+ ])
326
+
327
+
328
+ def _tokens(text):
329
+ return {t for t in re.split(r"[^A-Za-z0-9]+", text.lower())
330
+ if t and t not in _UNDISTINGUISHING}
331
+
332
+
333
+ def _marker_scope(text):
334
+ """(carries a marker, codes it names). An empty code set means bare.
335
+
336
+ The first thing after a marker decides. `# nosec B608` names an opaque
337
+ analyser code, `# noqa: decided, TICKET-9` is somebody talking -- neither
338
+ can be compared with a rule id, so both read as bare and cover the line
339
+ the way they always did. A marker whose first code is legible narrows to
340
+ what it names.
341
+ """
342
+ if not any(marker in text for marker in SUPPRESSION_MARKERS):
343
+ return False, set()
344
+ codes = set()
345
+ for match in _MARKER_CODES.finditer(text):
346
+ named = match.group(1)
347
+ if not named:
348
+ return True, set() # bare: about whatever is on the line
349
+ parts = [p for p in re.split(r"[,\s]+", named.strip()) if p]
350
+ if not parts or not _SPEAKING_CODE.match(parts[0]):
351
+ return True, set()
352
+ codes.update(p for p in parts if _SPEAKING_CODE.match(p))
353
+ return True, codes
354
+
355
+
356
+ def _marker_is_about(rule, codes):
357
+ """Does a marker naming these codes speak about this rule?
358
+
359
+ A marker that names nothing is about the line and covers anything on it,
360
+ which is how 105 of OpenSPP's 106 `.sudo().search(` findings were read.
361
+ A marker that DOES name something has said what it is about, and reading
362
+ it as covering everything is the mute button this project will not build
363
+ -- reached from the other direction. Classifying 50 findings by hand found
364
+ one: a consent-receipt timestamp silenced by a
365
+ `# nosemgrep: odoo-sudo-without-context` four lines below it, which is a
366
+ marker about sudo suppressing a finding about datetimes.
367
+
368
+ Matched on shared words rather than a mapping table, because no table
369
+ exists between somebody else's rule ids and ours, and `crosswalk` builds
370
+ one only for the analyser it was pointed at. Vendor and language words do
371
+ not count, so `odoo-sudo-without-context` and
372
+ `odoo.sudo-bypasses-record-rules` meet on `sudo` and not on `odoo`.
373
+
374
+ The direction of error is stated: a code this cannot relate to a rule
375
+ leaves the finding REPORTED, which is the side that shows too much rather
376
+ than the side that hides.
377
+
378
+ What is left open, and named rather than quietly accepted: an opaque code
379
+ still covers the line. `# noqa: E501` is a decision about line length and
380
+ reads here as a decision about whatever else is on that line, because
381
+ E501 carries no words to compare. Closing it means a table of another
382
+ analyser's codes, which is what `crosswalk` builds for one analyser at a
383
+ time and is not something this function can assume it has.
384
+ """
385
+ if not codes:
386
+ return True
387
+ subject = _tokens(rule.get("id", "").split(".", 1)[-1])
388
+ subject.update(_tokens(" ".join(rule.get("tags", []) or [])))
389
+ return any(_tokens(code) & subject for code in codes)
390
+
391
  # Statement kinds a marker is allowed to reach across. Every one of these is a
392
  # SIMPLE statement: it has no body of its own, so its line span is the span of
393
  # one expression and nothing else can hide inside it. Compound statements are
 
397
  ast.Return, ast.Raise, ast.Assert, ast.Delete)
398
 
399
 
400
+ def _advisory_grades():
401
+ """The likelihoods that make a finding advisory, from the one list.
402
+
403
+ Imported lazily because corpus imports this module, and the grades a
404
+ finding can carry must be one list rather than one here and another in
405
+ the validator -- the same reason evidence hashing lives in one place.
406
+ """
407
+ import corpus # local: corpus imports layers, so not at module level
408
+ return corpus.ADVISORY_LIKELIHOODS
409
+
410
+
411
  def _simple_statement_spans(body):
412
+ """(start, end, own_start) for every simple statement, widest last.
413
+
414
+ `start` reaches one line above the statement to take in a marker written
415
+ there; `own_start` is where the statement itself begins. Both are needed
416
+ because the borrowed line makes two statements overlap, and a finding on
417
+ the first line of one is inside the borrowed line of the one below it. On
418
+ OpenSPP that mattered exactly once and read as a missing suppression:
419
+ `self.client_id.sudo().write(...)` on line 37 carries its nosemgrep on 36
420
+ and is followed by a `return` on 38, whose own span borrows 37. Two spans
421
+ of equal width contained the line and the wrong one was picked.
422
 
423
  A finding anchors on one line, and a suppression marker written about that
424
  finding may sit on another line of the SAME expression. Measured on
 
451
  # 36 of OpenSPP's 105 sit there, above a statement that fits on
452
  # one line and has no room for anything else. Reading the same
453
  # convention the marker was written under is not obeying it.
454
+ spans.append((max(1, start - 1), end, start))
455
  return spans
456
 
457
 
 
537
  # call will read as unsuppressed when a person had in fact marked it.
538
  spans = _simple_statement_spans(body) if comment_kind(stack) == "python" else []
539
 
540
+ def _speaks_to(text, rule):
541
+ carries, codes = _marker_scope(text)
542
+ return carries and _marker_is_about(rule, codes)
543
 
544
+ def marked(line, rule):
545
  text = rows[line - 1] if 0 < line <= len(rows) else ""
546
+ if _speaks_to(text, rule):
547
  return True
548
  # The smallest simple statement containing this line, if any: a marker
549
  # written inside one expression covers that expression and stops there.
550
+ #
551
+ # A statement that really covers the line beats one that only reaches
552
+ # it through the comment line it borrowed from above. Without that,
553
+ # two spans of equal width tie and the loser is whichever ast.walk
554
+ # happened to reach first.
555
  best = None
556
+ for start, end, own in spans:
557
+ if not start <= line <= end:
558
+ continue
559
+ rank = (0 if own <= line else 1, end - start)
560
+ if best is None or rank < best[0]:
561
+ best = (rank, start, end)
562
  if best is None:
563
  return False
564
+ _, start, end = best
565
+ return any(_speaks_to(rows[n - 1], rule)
566
+ for n in range(start, end + 1) if 0 < n <= len(rows))
567
 
568
  # One parse for the whole file, before any semantic checker runs, so that
569
  # "nothing to say" and "could not read it" stop being the same output. Only
 
604
  continue
605
  seen.add(line)
606
  out.append(SourceHit(rule["id"], rule["severity"], line,
607
+ line_at(body, line), marked(line, rule),
608
  bool(rule.get("advisory")),
609
  rule.get("likelihood", "unmeasured")))
610
  elif rule.get("semantic_check"):
 
620
  for line, message, grade in found:
621
  # A grade means a trigger fired on THIS finding and earned it a
622
  # likelihood of its own. Absent, the rule's grade stands.
623
+ #
624
+ # Advisory follows the grade, per hit as well as per rule. The
625
+ # corpus already derives a rule's `advisory` flag from its
626
+ # likelihood, and a finding that earned `unproven` on its own
627
+ # evidence has earned the same treatment: reported, out of the
628
+ # rate, gating nothing. Without this a clause measured at zero
629
+ # true positives would gate a build on the strength of the
630
+ # clause beside it, which is the rule's grade answering for a
631
+ # finding it was not measured on.
632
  out.append(SourceHit(rule["id"], rule["severity"], line, message,
633
+ marked(line, rule),
634
+ bool(rule.get("advisory"))
635
+ or grade in _advisory_grades(),
636
  grade or rule.get("likelihood",
637
  "unmeasured")))
638
  if unparsed is not None and gaps is not None:
bin/semantics.py CHANGED
@@ -398,6 +398,15 @@ def _declared_covers(read, declared):
398
  return any(d == read or d.startswith(read + ".") for d in declared)
399
 
400
 
 
 
 
 
 
 
 
 
 
401
  def depends_covers_compute_reads(code, gaps=None):
402
  """odoo.stored-compute-needs-complete-depends
403
 
@@ -467,6 +476,24 @@ def depends_covers_compute_reads(code, gaps=None):
467
  continue
468
  registered = set(declared)
469
  produced = _assigned_attrs(fn)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  missing = {}
471
  for path, lineno in _field_reads(fn, _aliases(fn)):
472
  while len(path) > 1 and path[-1] in ORM_ATTRS:
@@ -885,21 +912,40 @@ def _is_true(node):
885
  return isinstance(node, ast.Constant) and node.value is True
886
 
887
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
888
  def _transient_spans(tree):
889
- """Line ranges of every TransientModel class in this tree.
890
 
891
  A wizard's record is vacuumed, so the failure both ondelete-is-a-decision
892
  and stored-compute-needs-complete-depends describe -- a value written once
893
- and read wrong much later -- has no time in which to happen. Returned as
894
- spans because the declarations and methods they gate are found by walking
895
- the whole tree, which loses the class on the way down.
 
 
896
 
897
  check_company_missing_on_company_owned_relation already had this exemption
898
  and iterates classes directly; these two do not, hence the spans.
899
  """
900
  return [(n.lineno, getattr(n, "end_lineno", n.lineno))
901
  for n in ast.walk(tree)
902
- if isinstance(n, ast.ClassDef) and _is_transient(n)]
903
 
904
 
905
  def _within(spans, lineno):
@@ -1837,78 +1883,822 @@ def declares_multi_company(code):
1837
  return any(marker in view for marker in MULTI_COMPANY_MARKERS)
1838
 
1839
 
1840
- def sudo_search_drops_a_real_rule(code, facts=None):
1841
- """odoo.sudo-bypasses-record-rules
 
 
 
 
 
 
 
 
 
1842
 
1843
- Fires on `<model>.sudo().search(...)` where the model actually carries an
1844
- ir.rule that filters something, and the domain does not re-apply company
1845
- scoping by hand.
1846
-
1847
- The regex this replaces asked only the first half of the question -- is
1848
- there a `.sudo().search(` -- and on OpenSPP that was 106 findings of which
1849
- 6 were the thing the rule describes. The rule's own sentence is that sudo
1850
- "drops ir.rule filtering". Where the tree declares no filtering rule for
1851
- that model, there is nothing to drop and the finding is about a mechanism
1852
- that does not exist here. Classified by hand, that was 83 of the 106:
1853
- 73 on models with no rule at all, and 10 on spp.vocabulary.code, whose
1854
- single rule is `[(1, '=', 1)]` and filters nothing.
1855
-
1856
- The trigger only ever SUPPRESSES. A model the walk could not name, a tree
1857
- that was not walked, a partial walk -- each reads UNKNOWN and the finding
1858
- stands, because "no rule was found" and "nobody looked" must not be the
1859
- same answer on a critical rule. `nexa check` on one file therefore behaves
1860
- exactly as it did before this trigger existed.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1861
 
1862
- What it still over-reports, measured and not yet fixed: 14 of the remaining
1863
- 20 constrain the search to records the caller had already identified -- a
1864
- group id, a partner id, an area -- which is the rule's advice followed in a
1865
- spelling the rule does not recognise, since it names only company_id. That
1866
- is a worklist entry rather than a narrowing, because deciding in general
1867
- that a domain is already scoped means evaluating it, and a narrowing that
1868
- guesses would cost the 6 true positives this keeps.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1869
  """
1870
  tree, _ = parse_python(code)
1871
  if tree is None:
1872
  return []
1873
 
1874
- findings = []
 
1875
  for node in ast.walk(tree):
1876
- if not isinstance(node, ast.Call):
1877
- continue
1878
- if not isinstance(node.func, ast.Attribute):
1879
  continue
1880
- if node.func.attr not in ("search", "search_count", "search_read"):
1881
- continue
1882
- inner = node.func.value
1883
- if not (isinstance(inner, ast.Call) and isinstance(inner.func, ast.Attribute)
1884
- and inner.func.attr == "sudo"):
1885
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1886
 
1887
- # The rule's own correct sample re-applies company filtering inside the
1888
- # domain, so a domain naming company_id is the rule satisfied. Read off
1889
- # the call's own arguments rather than the file, because a company_id
1890
- # anywhere else in a module said nothing about THIS search.
1891
- try:
1892
- call_src = ast.unparse(node)
1893
- except Exception: # pragma: no cover
1894
- call_src = ""
1895
- if "company_id" in call_src:
1896
- continue
1897
 
1898
- model = _env_model_of(inner.func.value)
1899
- if context.model_is_record_ruled(facts, model) == context.ABSENT:
1900
- continue
1901
 
1902
- where = " on {0}".format(model) if model else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1903
  findings.append((
1904
  node.lineno,
1905
- "sudo().{0}(){1} runs with record rules off -- ir.rule filtering, "
1906
- "including multi-company isolation, does not apply and the result "
1907
- "looks correct in single-company testing".format(node.func.attr, where),
1908
  None))
1909
  return findings
1910
 
1911
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1912
  def _env_model_of(node):
1913
  """The model name at the head of a chained call, or None.
1914
 
@@ -1932,7 +2722,11 @@ def _env_model_of(node):
1932
 
1933
  CHECKS = {
1934
  "handler_order_shadows_a_subclass": handler_order_shadows_a_subclass,
1935
- "sudo_search_drops_a_real_rule": sudo_search_drops_a_real_rule,
 
 
 
 
1936
  "check_company_missing_on_company_owned_relation":
1937
  check_company_missing_on_company_owned_relation,
1938
  "commit_inside_a_loop": commit_inside_a_loop,
@@ -1954,7 +2748,7 @@ CONTEXT_CHECKS = frozenset([
1954
  "check_company_missing_on_company_owned_relation",
1955
  # Reads models_record_ruled: whether sudo drops anything depends on whether
1956
  # the tree declares a rule that filters.
1957
- "sudo_search_drops_a_real_rule",
1958
  # Reads odoo_series: whether a _sql_constraints block is a constraint at
1959
  # all depends on which Odoo is going to load it.
1960
  "prefer_sql_constraint_applies",
@@ -2167,8 +2961,81 @@ def unresolvable_imports(tree):
2167
 
2168
  # ------------------------------------------------------- comment stripping
2169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2170
  def strip_comments(code, kind="python"):
2171
- """Code with its comments removed, quotes and strings left intact.
2172
 
2173
  WHY: a violation_pattern and a satisfied_pattern are regexes, and a regex
2174
  cannot tell code from prose. The adversarial counterexample probe put a
@@ -2182,12 +3049,18 @@ def strip_comments(code, kind="python"):
2182
  as a live violation, which is the false positive that teaches people to
2183
  ignore a checker.
2184
 
 
 
 
 
 
2185
  Newlines inside removed comments are preserved so that multi-line patterns
2186
  still see the line structure they were written against.
2187
  """
2188
  out = []
2189
  i, n = 0, len(code)
2190
  triple = ("'''", '"""')
 
2191
 
2192
  while i < n:
2193
  ch = code[i]
@@ -2196,7 +3069,7 @@ def strip_comments(code, kind="python"):
2196
  marker = code[i:i + 3]
2197
  end = code.find(marker, i + 3)
2198
  end = n if end < 0 else end + 3
2199
- out.append(code[i:end])
2200
  i = end
2201
  continue
2202
 
@@ -2212,7 +3085,7 @@ def strip_comments(code, kind="python"):
2212
  if code[j] == "\n" and kind == "python":
2213
  break # unterminated single-quoted string
2214
  j += 1
2215
- out.append(code[i:j])
2216
  i = j
2217
  continue
2218
 
 
398
  return any(d == read or d.startswith(read + ".") for d in declared)
399
 
400
 
401
+ # Fields the ORM adds to every model itself, with a declaration this corpus
402
+ # can read and a default of NOT stored. A model that wants one of these in the
403
+ # database says so in its own file, and then the ordinary storedness test sees
404
+ # it. Only display_name is here: the other automatic fields -- id, create_uid,
405
+ # create_date, write_uid, write_date -- are never computed by module code, so
406
+ # listing them would be describing a shape nobody writes.
407
+ FRAMEWORK_UNSTORED = frozenset(["display_name"])
408
+
409
+
410
  def depends_covers_compute_reads(code, gaps=None):
411
  """odoo.stored-compute-needs-complete-depends
412
 
 
476
  continue
477
  registered = set(declared)
478
  produced = _assigned_attrs(fn)
479
+ if produced and produced <= FRAMEWORK_UNSTORED and \
480
+ storedness.get(fn.name) is None:
481
+ # A field the framework adds to every model, which this file does
482
+ # not redeclare. "Unknown storedness" is the right reading for an
483
+ # ordinary field declared in another module; it is the wrong one
484
+ # here, because the framework's own declaration is visible and
485
+ # says not stored. odoo/models.py adds display_name as
486
+ # `fields.Char(automatic=True, compute='_compute_display_name',
487
+ # search=...)` with no store, so an override that omits a
488
+ # dependency cannot leave a stale row: there is no row.
489
+ #
490
+ # 121 of the 1051 findings this rule made on odoo core were
491
+ # _compute_display_name overrides of exactly that shape. The
492
+ # narrowing costs nothing measurable: all 10 hand-verified true
493
+ # positives that compute display_name -- every one of them in
494
+ # OpenSPP -- declare `store=True` on it in their own file, so
495
+ # storedness reads True for them and they are untouched.
496
+ continue
497
  missing = {}
498
  for path, lineno in _field_reads(fn, _aliases(fn)):
499
  while len(path) > 1 and path[-1] in ORM_ATTRS:
 
912
  return isinstance(node, ast.Constant) and node.value is True
913
 
914
 
915
+ def _is_sql_view(cls):
916
+ """True for a model declaring `_auto = False`: a SQL view, not a table.
917
+
918
+ Odoo builds no table for one and therefore no foreign key, so `ondelete`
919
+ is not a decision anybody can make about it -- there is no constraint for
920
+ a value to be chosen for. Two of the 15 sampled ondelete findings on odoo
921
+ core were report models of exactly this kind, hr.contract.history and
922
+ purchase.bill.line.match.
923
+ """
924
+ for node in cls.body:
925
+ if not isinstance(node, ast.Assign):
926
+ continue
927
+ if any(isinstance(t, ast.Name) and t.id == "_auto" for t in node.targets):
928
+ return isinstance(node.value, ast.Constant) and node.value.value is False
929
+ return False
930
+
931
+
932
  def _transient_spans(tree):
933
+ """Line ranges of every class this rule family does not apply to.
934
 
935
  A wizard's record is vacuumed, so the failure both ondelete-is-a-decision
936
  and stored-compute-needs-complete-depends describe -- a value written once
937
+ and read wrong much later -- has no time in which to happen. A `_auto =
938
+ False` model has no table at all, which rules out the same failure for a
939
+ different reason: see _is_sql_view. Returned as spans because the
940
+ declarations and methods they gate are found by walking the whole tree,
941
+ which loses the class on the way down.
942
 
943
  check_company_missing_on_company_owned_relation already had this exemption
944
  and iterates classes directly; these two do not, hence the spans.
945
  """
946
  return [(n.lineno, getattr(n, "end_lineno", n.lineno))
947
  for n in ast.walk(tree)
948
+ if isinstance(n, ast.ClassDef) and (_is_transient(n) or _is_sql_view(n))]
949
 
950
 
951
  def _within(spans, lineno):
 
1883
  return any(marker in view for marker in MULTI_COMPANY_MARKERS)
1884
 
1885
 
1886
+ # ------------------------------------------- where a value came from
1887
+ #
1888
+ # Four rules were classified false for the same reason: each matched a
1889
+ # variable's NAME and called that evidence about its value. `total == 0` is a
1890
+ # float comparison in one function and an exact integer test in the next;
1891
+ # `filename` says nothing about whether a caller chose it. Telling those apart
1892
+ # needs the assignment, which is what this section reads.
1893
+ #
1894
+ # It is a local trace and nothing more. One file, one function, no imports
1895
+ # followed and no types inferred -- which is enough for the shapes that were
1896
+ # actually misread, and the limit is stated here rather than discovered later.
1897
 
1898
+ def _function_of(tree):
1899
+ """Maps every node to the function that encloses it, innermost winning.
1900
+
1901
+ Lambdas count. `filtered(lambda x: x.qty == 0)` is where one of the
1902
+ misreadings lived, and a lambda binds its arguments exactly as a def does.
1903
+ """
1904
+ owner = {}
1905
+ scopes = [n for n in ast.walk(tree)
1906
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda))]
1907
+ # Outermost first, so an inner scope overwrites the outer one's claim.
1908
+ scopes.sort(key=lambda n: (getattr(n, "lineno", 0),
1909
+ getattr(n, "col_offset", 0)))
1910
+ for fn in scopes:
1911
+ for node in ast.walk(fn):
1912
+ owner[id(node)] = fn
1913
+ return owner
1914
+
1915
+
1916
+ def _final_name(node):
1917
+ """The last identifier in a Name or Attribute, or None for anything else."""
1918
+ if isinstance(node, ast.Name):
1919
+ return node.id
1920
+ if isinstance(node, ast.Attribute):
1921
+ return node.attr
1922
+ return None
1923
+
1924
+
1925
+ def _chain_root(node):
1926
+ """The leftmost Name in a chain of attributes, calls and subscripts."""
1927
+ while True:
1928
+ if isinstance(node, ast.Attribute):
1929
+ node = node.value
1930
+ elif isinstance(node, ast.Subscript):
1931
+ node = node.value
1932
+ elif isinstance(node, ast.Call):
1933
+ node = node.func
1934
+ else:
1935
+ break
1936
+ return node.id if isinstance(node, ast.Name) else None
1937
 
1938
+
1939
+ def _targets(node):
1940
+ """Every Name a statement binds, whatever the binding form."""
1941
+ found = []
1942
+ for sub in ast.walk(node):
1943
+ if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store, ast.Del)):
1944
+ found.append(sub.id)
1945
+ return found
1946
+
1947
+
1948
+ def _bound_in(fn):
1949
+ """Names this scope binds: parameters, assignments, loops, withs, imports."""
1950
+ if fn is None:
1951
+ return set()
1952
+ names = set()
1953
+ args = getattr(fn, "args", None)
1954
+ if args is not None:
1955
+ for group in (args.args, args.posonlyargs, args.kwonlyargs):
1956
+ names.update(a.arg for a in group or [])
1957
+ for extra in (args.vararg, args.kwarg):
1958
+ if extra is not None:
1959
+ names.add(extra.arg)
1960
+ for node in ast.walk(fn):
1961
+ if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.For,
1962
+ ast.AsyncFor, ast.comprehension, ast.NamedExpr)):
1963
+ names.update(_targets(node))
1964
+ elif isinstance(node, (ast.With, ast.AsyncWith)):
1965
+ for item in node.items:
1966
+ if item.optional_vars is not None:
1967
+ names.update(_targets(item.optional_vars))
1968
+ elif isinstance(node, ast.ExceptHandler) and node.name:
1969
+ names.add(node.name)
1970
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
1971
+ names.update((a.asname or a.name).split(".")[0] for a in node.names)
1972
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
1973
+ if node is not fn:
1974
+ names.add(node.name)
1975
+ return names
1976
+
1977
+
1978
+ def _bound_at_module(tree):
1979
+ """Names the file itself binds at any level outside a function body."""
1980
+ names = set()
1981
+ for node in ast.walk(tree):
1982
+ if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
1983
+ names.update(_targets(node))
1984
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
1985
+ names.update((a.asname or a.name).split(".")[0] for a in node.names)
1986
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
1987
+ names.add(node.name)
1988
+ return names
1989
+
1990
+
1991
+ # Reads that are external by their own name, whatever the surrounding file
1992
+ # does with them. `request` is werkzeug's, reached through odoo.http; the
1993
+ # context is whatever the caller put there; both parsers turn somebody else's
1994
+ # bytes into objects.
1995
+ EXTERNAL_ROOTS = frozenset(["request"])
1996
+ EXTERNAL_CALLS = frozenset(["loads", "literal_eval", "parse_qs", "parse_qsl"])
1997
+ CONTEXT_READS = ("_context", "context")
1998
+
1999
+
2000
+ def _is_external(node, fn, tree):
2001
+ """True when this expression's value came from outside the file.
2002
+
2003
+ Two ways to qualify. Either the expression names a source that is external
2004
+ by construction -- `request`, a parsed payload, the context a caller
2005
+ handed in -- or its root name is one this file never binds, which is the
2006
+ same answer arrived at by elimination: a name that is not a parameter, not
2007
+ assigned, not a loop or with target, not imported and not a builtin is a
2008
+ value from somewhere this file cannot see.
2009
+
2010
+ The elimination half is what makes the rules' own 'wrong' samples fire,
2011
+ because a sample is a fragment and its inputs are free names by
2012
+ definition. It is also what keeps them quiet on the four findings that
2013
+ were classified false: `file_config`, `filename`, `full_path` and
2014
+ `raw_value` are all bound a few lines above the join.
2015
+ """
2016
+ for sub in ast.walk(node):
2017
+ if isinstance(sub, ast.Attribute) and sub.attr in CONTEXT_READS:
2018
+ return True
2019
+ if isinstance(sub, ast.Call):
2020
+ attr = _final_name(sub.func)
2021
+ if attr in EXTERNAL_CALLS:
2022
+ return True
2023
+ root = _chain_root(node)
2024
+ if root is None:
2025
+ return False
2026
+ if root in EXTERNAL_ROOTS:
2027
+ return True
2028
+ if root in ("self", "cls"):
2029
+ return False
2030
+ if root in dir(builtins):
2031
+ return False
2032
+ return root not in _bound_in(fn) and root not in _bound_at_module(tree)
2033
+
2034
+
2035
+ # Calls whose result is a whole number, so `== 0` on it is exact and the
2036
+ # float rule has nothing to say. search_count and len are counts by
2037
+ # definition; `.count()` is one on a recordset, a string and a list alike.
2038
+ INT_VALUED_CALLS = frozenset(["search_count", "count", "len", "bit_length",
2039
+ "index", "ord"])
2040
+ _COUNT_KEY = re.compile(r"(?:^__count$|_count$)")
2041
+
2042
+
2043
+ def _integer_fields(tree):
2044
+ """Field names the file declares as fields.Integer."""
2045
+ names = set()
2046
+ for node in ast.walk(tree):
2047
+ if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call):
2048
+ continue
2049
+ func = node.value.func
2050
+ if isinstance(func, ast.Attribute) and func.attr == "Integer":
2051
+ names.update(_targets(node))
2052
+ return names
2053
+
2054
+
2055
+ def _counts_rows(fn):
2056
+ """True when this scope runs a COUNT query, so a fetched cell is an int."""
2057
+ if fn is None:
2058
+ return False
2059
+ for node in ast.walk(fn):
2060
+ if not (isinstance(node, ast.Call) and _final_name(node.func) == "execute"):
2061
+ continue
2062
+ for arg in node.args[:1]:
2063
+ try:
2064
+ text = ast.unparse(arg)
2065
+ except Exception: # pragma: no cover
2066
+ continue
2067
+ if "count(" in text.lower():
2068
+ return True
2069
+ return False
2070
+
2071
+
2072
+ def _assigned_values(fn, name):
2073
+ """Values assigned to `name` in this scope, or None if it is bound some
2074
+ way the trace cannot read -- a loop target, a with, an unpacking."""
2075
+ if fn is None:
2076
+ return None
2077
+ values = []
2078
+ for node in ast.walk(fn):
2079
+ if isinstance(node, ast.Assign):
2080
+ for target in node.targets:
2081
+ if isinstance(target, ast.Name) and target.id == name:
2082
+ values.append(node.value)
2083
+ elif name in _targets(target):
2084
+ return None # unpacked: no single value
2085
+ elif isinstance(node, ast.AnnAssign):
2086
+ if isinstance(node.target, ast.Name) and node.target.id == name \
2087
+ and node.value is not None:
2088
+ values.append(node.value)
2089
+ elif isinstance(node, (ast.For, ast.AsyncFor, ast.comprehension)):
2090
+ if name in _targets(node.target):
2091
+ return None
2092
+ elif isinstance(node, (ast.With, ast.AsyncWith)):
2093
+ for item in node.items:
2094
+ if item.optional_vars is not None \
2095
+ and name in _targets(item.optional_vars):
2096
+ return None
2097
+ elif isinstance(node, ast.AugAssign):
2098
+ if isinstance(node.target, ast.Name) and node.target.id == name:
2099
+ values.append(node.value)
2100
+ return values or None
2101
+
2102
+
2103
+ def _int_valued(node, fn, tree, depth=0):
2104
+ """True when this expression is certainly a whole number.
2105
+
2106
+ Certainly, not probably: every answer here SUPPRESSES a finding, so the
2107
+ unreadable cases must come back False and leave the finding standing. A
2108
+ name assigned twice is only an integer if both assignments are.
2109
+ """
2110
+ if depth > 4: # pragma: no cover
2111
+ return False
2112
+ if isinstance(node, ast.Constant):
2113
+ return isinstance(node.value, int) and not isinstance(node.value, bool)
2114
+ if isinstance(node, ast.Call):
2115
+ if _final_name(node.func) in INT_VALUED_CALLS:
2116
+ return True
2117
+ return False
2118
+ if isinstance(node, ast.Subscript):
2119
+ # read_group hands back rows keyed '<field>_count', and the key is
2120
+ # written as an f-string as often as a literal.
2121
+ key = node.slice
2122
+ if isinstance(key, ast.Constant) and isinstance(key.value, str):
2123
+ if _COUNT_KEY.search(key.value):
2124
+ return True
2125
+ if isinstance(key, ast.JoinedStr) and key.values:
2126
+ tail = key.values[-1]
2127
+ if isinstance(tail, ast.Constant) and isinstance(tail.value, str) \
2128
+ and _COUNT_KEY.search(tail.value):
2129
+ return True
2130
+ # cr.fetchone()[0] of a SELECT COUNT(*) is the third spelling of a
2131
+ # count, and the query is the only place that says so.
2132
+ inner = node.value
2133
+ if isinstance(inner, ast.Call) and _final_name(inner.func) in (
2134
+ "fetchone", "fetchall") and _counts_rows(fn):
2135
+ return True
2136
+ return False
2137
+ if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub,
2138
+ ast.Mult, ast.Mod,
2139
+ ast.FloorDiv)):
2140
+ return (_int_valued(node.left, fn, tree, depth + 1)
2141
+ and _int_valued(node.right, fn, tree, depth + 1))
2142
+ if isinstance(node, ast.Attribute):
2143
+ return node.attr in _integer_fields(tree)
2144
+ if isinstance(node, ast.Name):
2145
+ if node.id in _integer_fields(tree):
2146
+ return True
2147
+ values = _assigned_values(fn, node.id)
2148
+ if not values:
2149
+ return False
2150
+ return all(_int_valued(v, fn, tree, depth + 1) for v in values)
2151
+ return False
2152
+
2153
+
2154
+ # ------------------------------------------------ the rewritten checkers
2155
+
2156
+ _MONEY_NAME = re.compile(r"price|amount|qty|quantity|total|balance", re.I)
2157
+ _RESOLVES_THE_PATH = frozenset(["commonpath", "commonprefix", "relpath",
2158
+ "is_relative_to", "realpath", "abspath"])
2159
+
2160
+
2161
+ def float_equality_against_zero(code):
2162
+ """odoo.float-comparison-needs-float-utils
2163
+
2164
+ `== 0` or `!= 0` on a value whose name says it is money or a quantity,
2165
+ where the assignment does not say it is a whole number.
2166
+
2167
+ The regex this replaces asked only the first half. Classified by hand it
2168
+ was 2 true and 5 false, and every one of the five compared an INTEGER to
2169
+ zero, where `==` is exact and the rule has nothing to say:
2170
+ `search_count(...)`, a read_group `<field>_count`, `cr.fetchone()[0]` of a
2171
+ SELECT COUNT(*), and `x.qty` where the same file declares qty as
2172
+ fields.Integer. All five were named `total`, `group_total` or
2173
+ `total_population`, and so were both true positives -- `total =
2174
+ sum(sorted_amounts)` is the same spelling with a float behind it. The name
2175
+ could never have separated them.
2176
+
2177
+ What it cannot see, and so still reports: a float that arrives as a
2178
+ parameter, and an integer from a helper in another file. Both leave the
2179
+ finding standing, which is the direction a suppressing trace has to err in.
2180
  """
2181
  tree, _ = parse_python(code)
2182
  if tree is None:
2183
  return []
2184
 
2185
+ owner = _function_of(tree)
2186
+ findings, seen = [], set()
2187
  for node in ast.walk(tree):
2188
+ if not isinstance(node, ast.Compare) or len(node.ops) != 1:
 
 
2189
  continue
2190
+ if not isinstance(node.ops[0], (ast.Eq, ast.NotEq)):
 
 
 
 
2191
  continue
2192
+ left, right = node.left, node.comparators[0]
2193
+ for value, other in ((left, right), (right, left)):
2194
+ if not (isinstance(other, ast.Constant)
2195
+ and isinstance(other.value, (int, float))
2196
+ and not isinstance(other.value, bool)
2197
+ and other.value == 0):
2198
+ continue
2199
+ name = _final_name(value)
2200
+ if not name or not _MONEY_NAME.search(name):
2201
+ continue
2202
+ if _int_valued(value, owner.get(id(value)), tree):
2203
+ break
2204
+ if node.lineno in seen:
2205
+ break
2206
+ seen.add(node.lineno)
2207
+ findings.append((
2208
+ node.lineno,
2209
+ "'{0}' is compared with == against zero -- binary floats do "
2210
+ "not land on it exactly, so use float_is_zero with the "
2211
+ "field's precision".format(name),
2212
+ None))
2213
+ break
2214
+ return findings
2215
 
 
 
 
 
 
 
 
 
 
 
2216
 
2217
+ def path_join_takes_an_external_name(code):
2218
+ """python.path-join-does-not-contain-a-path
 
2219
 
2220
+ os.path.join where one component came from outside the file, and the
2221
+ result is not resolved and compared before it is used.
2222
+
2223
+ The regex this replaces matched component NAMES -- `filename`, `form`,
2224
+ `params` -- and was 0 true, 4 false. All four join a configured directory
2225
+ to a name the module itself produced: a timestamped backup filename, a
2226
+ literal from a list declared in the same function, a module path out of
2227
+ get_module_path. Not one was a fragment a caller could point out of the
2228
+ directory, which is the only thing the rule is about.
2229
+
2230
+ So the question moved from what the component is called to where its value
2231
+ came from, and 'outside the file' is the answer _is_external gives. The
2232
+ rule's own wrong sample joins `request.args['name']` and still fires.
2233
+
2234
+ This reports less than the regex did, on purpose: the regex's entire
2235
+ output on 294,000 lines was false, and a checker that cries wolf gets
2236
+ switched off. A traversal reached through a helper in another module is
2237
+ outside what a local trace can see and is not reported.
2238
+ """
2239
+ tree, _ = parse_python(code)
2240
+ if tree is None:
2241
+ return []
2242
+
2243
+ owner = _function_of(tree)
2244
+ findings = []
2245
+ for node in ast.walk(tree):
2246
+ if not (isinstance(node, ast.Call) and _final_name(node.func) == "join"):
2247
+ continue
2248
+ # os.path.join, not str.join and not pathlib's.
2249
+ parent = node.func.value if isinstance(node.func, ast.Attribute) else None
2250
+ if not (isinstance(parent, ast.Attribute) and parent.attr == "path"):
2251
+ continue
2252
+ fn = owner.get(id(node))
2253
+ # The fix is to resolve and compare, and it is written beside the
2254
+ # join. Scoped to the enclosing function rather than the file: a
2255
+ # realpath somewhere else in a 900-line model says nothing about this
2256
+ # call. A join at module scope -- which is what a rule's own sample
2257
+ # is -- is scoped to the module for the same reason.
2258
+ scope = fn if fn is not None else tree
2259
+ if any(_final_name(c.func) in _RESOLVES_THE_PATH
2260
+ for c in ast.walk(scope) if isinstance(c, ast.Call)):
2261
+ continue
2262
+ for arg in node.args[1:]:
2263
+ if _is_external(arg, fn, tree):
2264
+ try:
2265
+ shown = ast.unparse(arg)
2266
+ except Exception: # pragma: no cover
2267
+ shown = "the component"
2268
+ findings.append((
2269
+ node.lineno,
2270
+ "os.path.join is handed {0}, which came from outside this "
2271
+ "file -- an absolute component discards the base directory "
2272
+ "entirely and '..' walks out of it, so resolve both and "
2273
+ "compare before opening anything".format(shown),
2274
+ None))
2275
+ break
2276
+ return findings
2277
+
2278
+
2279
+ def float_coercion_of_external_money(code):
2280
+ """python.decimal-for-money-not-float
2281
+
2282
+ float() applied to a money value that arrived from outside the file.
2283
+
2284
+ The regex this replaces matched the NAME either side of the coercion and
2285
+ was 0 true, 3 false, with a reason behind it rather than a sample size:
2286
+ Odoo's Monetary field IS a float -- the ORM defines it that way -- and the
2287
+ framework's answer to the precision problem is float_round and
2288
+ float_compare against the currency's rounding, not Decimal. Inside an Odoo
2289
+ module the rule fired on the idiom and named a fix that cannot be taken.
2290
+
2291
+ Three findings do not settle a rule either way, so it is narrowed rather
2292
+ than culled, and the narrowing is the line the argument actually draws.
2293
+ `float(self.amount or 0.0)` is the ORM's own type and is not reported.
2294
+ `float(result)`, where result came back from a service in this file, is
2295
+ not reported. `float(payload['price'])`, where the string came from a
2296
+ caller and Decimal(payload['price']) IS available, still is -- which is
2297
+ the rule's own wrong sample and the one shape its remedy fits.
2298
+ """
2299
+ tree, _ = parse_python(code)
2300
+ if tree is None:
2301
+ return []
2302
+
2303
+ owner = _function_of(tree)
2304
+ findings = []
2305
+ for node in ast.walk(tree):
2306
+ if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
2307
+ and node.func.id == "float" and node.args):
2308
+ continue
2309
+ value = node.args[0]
2310
+ name = _final_name(value) or _chain_root(value) or ""
2311
+ target = _MONEY_NAME.search(name)
2312
+ if not target:
2313
+ # The coerced expression may say nothing, while what it is
2314
+ # assigned to does: `price = float(payload['p'])`.
2315
+ holder = owner.get(id(node))
2316
+ for stmt in ast.walk(holder if holder is not None else tree):
2317
+ if isinstance(stmt, ast.Assign) and stmt.value is node:
2318
+ target = any(_MONEY_NAME.search(t)
2319
+ for t in _targets(stmt) if t)
2320
+ break
2321
+ if not target:
2322
+ continue
2323
+ if not _is_external(value, owner.get(id(node)), tree):
2324
+ continue
2325
  findings.append((
2326
  node.lineno,
2327
+ "a money value from outside this file is routed through float() "
2328
+ "-- the binary float is already wrong before Decimal can be given "
2329
+ "it, so build Decimal from the original string",
2330
  None))
2331
  return findings
2332
 
2333
 
2334
+ def onchange_tries_to_enforce(code):
2335
+ """odoo.onchange-is-not-validation
2336
+
2337
+ An @api.onchange method that RAISES or returns a warning -- it is trying
2338
+ to enforce an invariant the web client is the only thing that will apply.
2339
+
2340
+ The regex this replaces read 400 characters after the decorator and could
2341
+ not see where the method ended. All three of its false positives are the
2342
+ same failure: a four-line onchange that assigns one field, followed by an
2343
+ action method that raises. `_onchange_variable_id` sets a target model and
2344
+ the UserError 200 characters below it belongs to `action_remap`. The
2345
+ window crossed the function boundary, and a regex has no way not to.
2346
+
2347
+ Scoped to the decorated function, that shape is silent, and the four true
2348
+ positives -- an onchange that really does raise ValidationError, and ones
2349
+ that return the warning dict -- all stand.
2350
+
2351
+ A constraint anywhere in the file clears it, exactly as the
2352
+ satisfied_pattern did: an onchange sitting beside a real @api.constrains
2353
+ is the faster feedback the rule explicitly allows. _sql_constraints counts
2354
+ here, because it IS a constraint on 16 through 18; the 19 hole is closed
2355
+ where the series is legible, in prefer_sql_constraint_applies.
2356
+ """
2357
+ tree, _ = parse_python(code)
2358
+ if tree is None:
2359
+ return []
2360
+
2361
+ backed = False
2362
+ for node in ast.walk(tree):
2363
+ if isinstance(node, ast.Name) and node.id == "_sql_constraints":
2364
+ backed = True
2365
+ elif isinstance(node, ast.Attribute) and node.attr in ("constrains",
2366
+ "Constraint"):
2367
+ backed = True
2368
+ if backed:
2369
+ return []
2370
+
2371
+ findings = []
2372
+ for fn in _functions(tree):
2373
+ if _decorator_args(fn, "api", "onchange") is None:
2374
+ continue
2375
+ # Anchored on the decorator, not on the def below it. That is where
2376
+ # the rule is, where a reader looking the finding up will look, and
2377
+ # where the marker goes when somebody has already decided about it --
2378
+ # a FunctionDef's own lineno is the `def`, one line past all three.
2379
+ at = fn.lineno
2380
+ for dec in fn.decorator_list:
2381
+ func = dec.func if isinstance(dec, ast.Call) else dec
2382
+ if isinstance(func, ast.Attribute) and func.attr == "onchange":
2383
+ at = dec.lineno
2384
+ break
2385
+ for node in ast.walk(fn):
2386
+ spot = None
2387
+ if isinstance(node, ast.Raise):
2388
+ exc = node.exc
2389
+ name = _final_name(exc.func) if isinstance(exc, ast.Call) \
2390
+ else _final_name(exc)
2391
+ if name and ("Error" in name or "Warning" in name):
2392
+ spot = node.lineno
2393
+ elif isinstance(node, ast.Dict):
2394
+ for key in node.keys:
2395
+ if isinstance(key, ast.Constant) and key.value == "warning":
2396
+ spot = node.lineno
2397
+ if spot is None:
2398
+ continue
2399
+ findings.append((
2400
+ at,
2401
+ "@api.onchange '{0}' refuses a value, and onchange runs in "
2402
+ "the web client form only -- an import, an RPC call, a cron "
2403
+ "or a direct create() never triggers it, so the record enters "
2404
+ "the database unchallenged".format(fn.name),
2405
+ None))
2406
+ break
2407
+ return findings
2408
+
2409
+
2410
+ # A query decides WHICH records you get to see, and dropping ir.rule widens
2411
+ # that set. Every one of the 6 hand-classified true positives is one of these.
2412
+ SUDO_QUERIES = ("search", "search_count", "search_read", "read_group")
2413
+ # These act on a recordset the caller already holds. Dropping ir.rule still
2414
+ # matters -- it governs write and read permission as well as visibility -- but
2415
+ # it widens what you may DO with a record rather than which records exist for
2416
+ # you, and nothing measured here has yet found a real one.
2417
+ SUDO_TOUCHES = ("create", "write", "unlink", "copy", "read")
2418
+
2419
+ # The grade the write and escape clauses carry, per finding rather than per
2420
+ # rule. `unproven` is the corpus's word for a real defect nothing measured
2421
+ # here has met yet, and that is exactly their position: the read clause has
2422
+ # 6 hand-classified true positives across two trees, and these two have 0 in
2423
+ # 261,000 lines. Every one of the 23 they found was read, and every one was
2424
+ # an administrator by construction -- a demo-data seeder, an ACL-gated
2425
+ # accounting wizard -- or a framework model with no tenant boundary at all.
2426
+ #
2427
+ # Grading them rather than deleting them is the same call §3.46 made about
2428
+ # decimal-for-money: a clause the rule's own sentence asks for, measured at
2429
+ # zero, is reported and not trusted to stop a build. It costs the read
2430
+ # clause nothing, because a grade travels with the finding and not the rule.
2431
+ UNPROVEN = "unproven"
2432
+
2433
+
2434
+ def _escalates(call):
2435
+ """True when this `.sudo(...)` call turns record rules OFF.
2436
+
2437
+ `sudo(False)` turns them back ON. It is how a method that must run under
2438
+ the caller's own rights says so, and odoo core does it 60 times --
2439
+ `self = self.sudo(False)` in mail's `_check_attachments_access`, whose
2440
+ own docstring reads "This method relies on access rules/rights and
2441
+ therefore it should not be called from a sudo env."
2442
+
2443
+ Reading it as an escalation reports the fix as the defect, which is worse
2444
+ than missing the defect: it is the one kind of finding that teaches a
2445
+ reader the checker cannot be trusted. An argument that is not a literal
2446
+ is unreadable here and counts as escalation, because `sudo(flag)` with a
2447
+ computed flag can be either and the finding should stand.
2448
+ """
2449
+ if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute)
2450
+ and call.func.attr == "sudo"):
2451
+ return False
2452
+ given = list(call.args) + [k.value for k in call.keywords if k.arg == "flag"]
2453
+ for arg in given:
2454
+ if isinstance(arg, ast.Constant) and not arg.value:
2455
+ return False
2456
+ # `x.sudo(self.env.su)` sets su to whatever the environment already
2457
+ # has, so it cannot raise privilege above ambient -- it is how a
2458
+ # method hands a record back at the caller's own level. odoo core
2459
+ # writes it four times and once with the comment "Unsudo the invoice
2460
+ # after creation if not already sudoed". `sudo(self.env.su or
2461
+ # field.compute_sudo)` is NOT this: the `or` can turn it on, so only
2462
+ # the bare attribute is read as ambient.
2463
+ if isinstance(arg, ast.Attribute) and arg.attr == "su":
2464
+ return False
2465
+ return True
2466
+
2467
+
2468
+ def _under_sudo(node):
2469
+ """The receiver of `<x>.sudo().<method>(...)`, or None."""
2470
+ if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
2471
+ return None
2472
+ inner = node.func.value
2473
+ if isinstance(inner, ast.Call) and _escalates(inner):
2474
+ return inner.func.value
2475
+ return None
2476
+
2477
+
2478
+ def sudo_drops_record_rules(code, facts=None):
2479
+ """odoo.sudo-bypasses-record-rules
2480
+
2481
+ Three clauses, because the rule has three: scope sudo to the narrowest
2482
+ operation, re-apply company filtering by hand, and never pass a sudo
2483
+ recordset onward.
2484
+
2485
+ 1. a read under sudo -- search, search_read, read_group, read
2486
+ 2. a write under sudo -- create, write, unlink, copy
2487
+ 3. a sudo recordset bound to a name and then handed to something else
2488
+
2489
+ Only the first was checked. The `--harvest` clustering says what that
2490
+ cost: `.sudo(` is the largest miss cluster in the corpus's recall
2491
+ measurement at 70 missed against 6 caught, and 47 of those 70 are files
2492
+ where a checker fired somewhere else -- a rule aimed wrong rather than a
2493
+ rule that is missing. Counted on the OpenSPP tree, the clause that was
2494
+ checked has 116 call sites and the clause that was not has 136, so the
2495
+ unchecked half was the larger one. One of the misses the README names,
2496
+ *"remove sudo() when importing record"*, is a create the search-only
2497
+ checker could not see at all.
2498
+
2499
+ ir.rule governs read, write, create and unlink alike, so the same
2500
+ record-rule trigger gates every clause: where the tree declares no rule
2501
+ that filters this model, sudo drops nothing and there is no finding. The
2502
+ trigger only ever SUPPRESSES. A model the walk could not name, a tree that
2503
+ was not walked, a partial walk -- each reads UNKNOWN and the finding
2504
+ stands, because "no rule was found" and "nobody looked" must not be the
2505
+ same answer on a critical rule. `nexa check` on one file therefore behaves
2506
+ exactly as it did before triggers existed.
2507
+
2508
+ Clause 3 fires at the binding, not at the use. `partners =
2509
+ self.env['res.partner'].sudo()` is a recordset carrying an environment
2510
+ with su=True, and every call made on it downstream inherits that -- which
2511
+ is the whole reason the rule's sentence says not to pass one on. Reported
2512
+ where the escape happens, once, rather than at each later read.
2513
+
2514
+ What it still over-reports, measured and not yet fixed: 14 of the 20
2515
+ surviving read findings constrain the search to records the caller had
2516
+ already identified -- a group id, a partner id, an area -- which is the
2517
+ rule's advice followed in a spelling the rule does not recognise, since it
2518
+ names only company_id. Deciding in general that a domain is already scoped
2519
+ means evaluating it, and a narrowing that guesses would cost the 6 true
2520
+ positives this keeps.
2521
+ """
2522
+ tree, _ = parse_python(code)
2523
+ if tree is None:
2524
+ return []
2525
+
2526
+ def rescoped(node, model):
2527
+ # Did this call re-apply the scoping the rule it bypasses is about?
2528
+ #
2529
+ # The old test was the literal word `company_id`, from the rule's own
2530
+ # correct sample. That is one spelling of one kind of rule, and the
2531
+ # docstring recorded what it cost: 14 of 20 surviving read findings
2532
+ # were scoped by a group, a partner or an area instead, and read as
2533
+ # unscoped. Every OpenSPP case model is governed by `case_worker_id`
2534
+ # or `case_id.case_worker_id` and carries no company rule at all.
2535
+ #
2536
+ # So the call is now compared against the fields the model's OWN
2537
+ # ir.rule filters on, which is not a guess about the domain -- it is
2538
+ # the domain, read off the tree that declares it. A create naming
2539
+ # `case_id` lands inside the caller's visibility because that is the
2540
+ # link the rule follows. company_id stays as the fallback for a model
2541
+ # whose rules this could not read, so nothing that was exempt before
2542
+ # stops being exempt now.
2543
+ # The ARGUMENTS only. Unparsing the whole call includes the receiver,
2544
+ # and the model name is part of it: `spp.group.membership` contains
2545
+ # the word `group`, which is one of that model's own rule fields, so
2546
+ # reading the whole chain exempted an unscoped sweep of every
2547
+ # membership in the database -- a finding classified true by hand.
2548
+ #
2549
+ # An argument that is a bare name is followed to what it was assigned,
2550
+ # because `create(vals)` otherwise hides every field it sets behind
2551
+ # four letters. The trace is the same local one the other checkers
2552
+ # use, and a name it cannot resolve contributes nothing.
2553
+ pieces = []
2554
+ for arg in list(node.args) + [k.value for k in node.keywords]:
2555
+ try:
2556
+ pieces.append(ast.unparse(arg))
2557
+ except Exception: # pragma: no cover
2558
+ continue
2559
+ if isinstance(arg, ast.Name):
2560
+ for value in _assigned_values(owner.get(id(node)), arg.id) or ():
2561
+ try:
2562
+ pieces.append(ast.unparse(value))
2563
+ except Exception: # pragma: no cover
2564
+ pass
2565
+ call_src = " ".join(pieces)
2566
+ fields = context.model_rule_fields(facts, model) if model else ()
2567
+ for name in fields or ("company_id",):
2568
+ if re.search(r"\b" + re.escape(name.split(".", 1)[0]) + r"\b", call_src):
2569
+ return True
2570
+ return "company_id" in call_src
2571
+
2572
+ owner = _function_of(tree)
2573
+
2574
+ def model_of(receiver):
2575
+ # `self.env['x'].sudo()` names its model in the chain. `Plan.sudo()`
2576
+ # does not, and every one of the write clause's first findings on
2577
+ # OpenSPP's demo generators was that shape -- `Plan =
2578
+ # self.env['spp.case.plan']` a few lines up, then fifteen writes on
2579
+ # the name. Unresolved, the trigger reads UNKNOWN and cannot suppress;
2580
+ # followed through the assignment, it can answer. Every assignment to
2581
+ # the name must agree, or the answer is still None.
2582
+ model = _env_model_of(receiver)
2583
+ if model is not None or not isinstance(receiver, ast.Name):
2584
+ return model
2585
+ values = _assigned_values(owner.get(id(receiver)), receiver.id)
2586
+ if not values:
2587
+ values = [v for n in ast.walk(tree) if isinstance(n, ast.Assign)
2588
+ for t in n.targets if isinstance(t, ast.Name)
2589
+ and t.id == receiver.id for v in (n.value,)]
2590
+ found = {_env_model_of(v) for v in values}
2591
+ return found.pop() if len(found) == 1 and None not in found else None
2592
+
2593
+ def ruled_out(receiver):
2594
+ model = model_of(receiver)
2595
+ return context.model_is_record_ruled(facts, model) == context.ABSENT, model
2596
+
2597
+ findings, seen = [], set()
2598
+
2599
+ def report(line, text, grade=None):
2600
+ if line not in seen:
2601
+ seen.add(line)
2602
+ findings.append((line, text, grade))
2603
+
2604
+ for node in ast.walk(tree):
2605
+ receiver = _under_sudo(node)
2606
+ if receiver is None:
2607
+ continue
2608
+ method = node.func.attr
2609
+ if method not in SUDO_QUERIES and method not in SUDO_TOUCHES:
2610
+ continue
2611
+ absent, model = ruled_out(receiver)
2612
+ if absent:
2613
+ continue
2614
+ if rescoped(node, model):
2615
+ continue
2616
+ where = " on {0}".format(model) if model else ""
2617
+ if method in SUDO_QUERIES:
2618
+ report(node.lineno,
2619
+ "sudo().{0}(){1} runs with record rules off -- ir.rule "
2620
+ "filtering, including multi-company isolation, does not "
2621
+ "apply and the result looks correct in single-company "
2622
+ "testing".format(method, where))
2623
+ else:
2624
+ report(node.lineno,
2625
+ "sudo().{0}(){1} acts with record rules off -- ir.rule "
2626
+ "governs write, create, unlink and read as well as "
2627
+ "visibility, so this reaches a record the caller's own "
2628
+ "rules would not let it touch".format(method, where),
2629
+ UNPROVEN)
2630
+
2631
+ # Clause 3: a sudo recordset given a name, then handed on.
2632
+ #
2633
+ # The bare name only, as an argument or as the whole returned value.
2634
+ # `return partners.mapped('name')` hands back a list of strings and the
2635
+ # elevated environment does not travel with it, while
2636
+ # `return partners.filtered(...)` does -- telling those apart means
2637
+ # knowing which recordset methods return recordsets, so neither is
2638
+ # reported. Clause 3 under-reports by construction, which is the right
2639
+ # direction for a new clause on a critical rule: the search clause's
2640
+ # first version fired 106 times for 6 real ones, and a flood is how a
2641
+ # checker gets switched off.
2642
+ for fn in _functions(tree):
2643
+ escapes = set()
2644
+ for node in ast.walk(fn):
2645
+ if isinstance(node, ast.Call):
2646
+ if _under_sudo(node) is None:
2647
+ escapes.update(a.id for a in node.args
2648
+ if isinstance(a, ast.Name))
2649
+ escapes.update(k.value.id for k in node.keywords
2650
+ if isinstance(k.value, ast.Name))
2651
+ elif isinstance(node, ast.Return) and isinstance(node.value, ast.Name):
2652
+ escapes.add(node.value.id)
2653
+ for node in ast.walk(fn):
2654
+ if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.Call):
2655
+ continue
2656
+ call = node.value
2657
+ if not _escalates(call):
2658
+ continue
2659
+ bound = [t for t in _targets(node)]
2660
+
2661
+ # `self = self.sudo()` rebinds the method's own receiver, and
2662
+ # every ORM call below it in the method inherits the elevated
2663
+ # environment without naming sudo anywhere. It is the escape
2664
+ # clause at its widest -- the recordset is not passed on, it
2665
+ # replaces the thing the whole method is written against.
2666
+ #
2667
+ # This is the miss the README names by hand. OCA server-tools
2668
+ # cbefaa511485, "[FIX] excel_import_export: remove sudo() when
2669
+ # importing record", deletes exactly this line and says what it
2670
+ # cost: "finding multiple records across all companies during
2671
+ # import when they have same record name. Previously, importing
2672
+ # with sudo could bypass multi-company record rules."
2673
+ #
2674
+ # Three sites in 260,000 lines, so it is narrow as well as real.
2675
+ # The same commit ADDS `self.sudo().env["ir.model.data"]`, a
2676
+ # deliberate and narrow elevation, which is the evidence for not
2677
+ # also flagging that shape.
2678
+ rebind = isinstance(call.func.value, ast.Name) \
2679
+ and call.func.value.id in bound
2680
+ if rebind:
2681
+ report(node.lineno,
2682
+ "'{0}' is rebound to its own sudo form, so every ORM "
2683
+ "call below this line in the method runs with record "
2684
+ "rules off -- including ones that name no sudo at all"
2685
+ .format(call.func.value.id))
2686
+ continue
2687
+
2688
+ if not any(name in escapes for name in bound):
2689
+ continue
2690
+ absent, model = ruled_out(call.func.value)
2691
+ if absent:
2692
+ continue
2693
+ where = " of {0}".format(model) if model else ""
2694
+ report(node.lineno,
2695
+ "this sudo recordset{0} is passed on -- the elevated "
2696
+ "environment travels with it, so every read and write made "
2697
+ "on it downstream also runs with record rules off"
2698
+ .format(where), UNPROVEN)
2699
+ return sorted(findings)
2700
+
2701
+
2702
  def _env_model_of(node):
2703
  """The model name at the head of a chained call, or None.
2704
 
 
2722
 
2723
  CHECKS = {
2724
  "handler_order_shadows_a_subclass": handler_order_shadows_a_subclass,
2725
+ "sudo_drops_record_rules": sudo_drops_record_rules,
2726
+ "float_equality_against_zero": float_equality_against_zero,
2727
+ "path_join_takes_an_external_name": path_join_takes_an_external_name,
2728
+ "float_coercion_of_external_money": float_coercion_of_external_money,
2729
+ "onchange_tries_to_enforce": onchange_tries_to_enforce,
2730
  "check_company_missing_on_company_owned_relation":
2731
  check_company_missing_on_company_owned_relation,
2732
  "commit_inside_a_loop": commit_inside_a_loop,
 
2748
  "check_company_missing_on_company_owned_relation",
2749
  # Reads models_record_ruled: whether sudo drops anything depends on whether
2750
  # the tree declares a rule that filters.
2751
+ "sudo_drops_record_rules",
2752
  # Reads odoo_series: whether a _sql_constraints block is a constraint at
2753
  # all depends on which Odoo is going to load it.
2754
  "prefer_sql_constraint_applies",
 
2961
 
2962
  # ------------------------------------------------------- comment stripping
2963
 
2964
+ def _docstring_starts(code):
2965
+ """Character offsets where a docstring literal opens.
2966
+
2967
+ A docstring is prose, and a regex cannot tell prose from code -- which is
2968
+ the whole reason comments are stripped before matching. String literals
2969
+ were left standing, and classifying 50 findings by hand found the cost:
2970
+ two of them were `eval()` named in a docstring in a sentence arguing
2971
+ AGAINST using it, and a `datetime.now()` inside a doctest example. Both
2972
+ read as live code.
2973
+
2974
+ Only docstrings, not every string. A triple-quoted SQL query is data the
2975
+ checkers have a legitimate interest in, and `_SQL_COLUMNS` reads one. The
2976
+ statement-level bare string an AST calls a docstring is the one place the
2977
+ file is guaranteed to be talking ABOUT code rather than in it.
2978
+
2979
+ Offsets, not spans: the scanner already knows how to find a literal's end,
2980
+ and asking it to do that is one way of measuring the string rather than
2981
+ two. `col_offset` is a byte offset, which parts company with a character
2982
+ offset on a line holding non-ASCII -- so the result is checked against the
2983
+ quote it claims to point at and dropped when it does not, leaving that
2984
+ docstring intact rather than blanking the wrong run of characters.
2985
+ """
2986
+ tree, _ = parse_python(code)
2987
+ if tree is None:
2988
+ return frozenset()
2989
+
2990
+ line_start, offset = [], 0
2991
+ for row in code.splitlines(True):
2992
+ line_start.append(offset)
2993
+ offset += len(row)
2994
+
2995
+ starts = set()
2996
+ holders = (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
2997
+ for node in ast.walk(tree):
2998
+ if not isinstance(node, holders):
2999
+ continue
3000
+ body = getattr(node, "body", None)
3001
+ if not body:
3002
+ continue
3003
+ first = body[0]
3004
+ if not (isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant)
3005
+ and isinstance(first.value.value, str)):
3006
+ continue
3007
+ lineno = getattr(first.value, "lineno", 0)
3008
+ if not 0 < lineno <= len(line_start):
3009
+ continue
3010
+ at = line_start[lineno - 1] + first.value.col_offset
3011
+ # The guard the docstring above describes. A prefix of anything but
3012
+ # indentation is possible -- `def f(): "doc"` -- but it is ASCII in
3013
+ # every spelling that reaches here, so a miss means the offset is
3014
+ # wrong and the safe answer is to leave the literal alone.
3015
+ if at < len(code) and code[at] in ("'", '"'):
3016
+ starts.add(at)
3017
+ return frozenset(starts)
3018
+
3019
+
3020
+ def _blanked(text):
3021
+ """A string literal with its contents emptied, quotes and shape intact.
3022
+
3023
+ Same length and the same newlines, so the line AND column a finding
3024
+ reports are the ones a person reading the file will see, and so a
3025
+ docstring that is the whole body of a function still leaves that function
3026
+ syntactically intact for anything that parses the stripped view.
3027
+ """
3028
+ if len(text) < 2:
3029
+ return text
3030
+ quote = 3 if text[:3] in ("'''", '"""') else 1
3031
+ head, body, tail = text[:quote], text[quote:-quote or None], text[-quote:]
3032
+ if len(text) < quote * 2:
3033
+ return text
3034
+ return head + "".join("\n" if c == "\n" else " " for c in body) + tail
3035
+
3036
+
3037
  def strip_comments(code, kind="python"):
3038
+ """Code with its comments and docstrings removed, strings left intact.
3039
 
3040
  WHY: a violation_pattern and a satisfied_pattern are regexes, and a regex
3041
  cannot tell code from prose. The adversarial counterexample probe put a
 
3049
  as a live violation, which is the false positive that teaches people to
3050
  ignore a checker.
3051
 
3052
+ A docstring is the same argument wearing a different quote. It was left
3053
+ standing here until classifying 50 findings by hand turned up two that were
3054
+ prose read as code, so docstrings are now emptied and left in place -- see
3055
+ _docstring_starts for why only docstrings and not every literal.
3056
+
3057
  Newlines inside removed comments are preserved so that multi-line patterns
3058
  still see the line structure they were written against.
3059
  """
3060
  out = []
3061
  i, n = 0, len(code)
3062
  triple = ("'''", '"""')
3063
+ docstrings = _docstring_starts(code) if kind == "python" else frozenset()
3064
 
3065
  while i < n:
3066
  ch = code[i]
 
3069
  marker = code[i:i + 3]
3070
  end = code.find(marker, i + 3)
3071
  end = n if end < 0 else end + 3
3072
+ out.append(_blanked(code[i:end]) if i in docstrings else code[i:end])
3073
  i = end
3074
  continue
3075
 
 
3085
  if code[j] == "\n" and kind == "python":
3086
  break # unterminated single-quoted string
3087
  j += 1
3088
+ out.append(_blanked(code[i:j]) if i in docstrings else code[i:j])
3089
  i = j
3090
  continue
3091
 
classification-odoo-core-2026-09.json ADDED
@@ -0,0 +1,598 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "generated_utc": "2026-09-21T22:37:59+00:00",
3
+ "stack": "odoo",
4
+ "checkouts": {
5
+ "odoo": "3c3e3b3d17cbd98584c7685e607d9085712adfe0"
6
+ },
7
+ "subject": "odoo/odoo 18.0, the framework itself",
8
+ "files": 5899,
9
+ "lines": 559663,
10
+ "scope": "A SAMPLE, not a census, and the first classification here that is one. odoo core produced 2,784 live findings, twenty-five times the size of any set read here before. 15 findings of each of the four largest rules were drawn with a fixed seed and read by hand against the file each sits in: 60 of the 2,784, covering the rules that make 70% of them. The draw reproduces from this description alone: take the live findings for the rule, drop the advisory and suppressed ones, sort by (path, line), and call random.Random('20260921:' + rule_id).sample(rows, 15).",
11
+ "totals": {
12
+ "true": 27,
13
+ "false": 33
14
+ },
15
+ "by_rule": {
16
+ "odoo.stored-compute-needs-complete-depends": {
17
+ "true": 11,
18
+ "false": 4,
19
+ "precision": "11/15"
20
+ },
21
+ "python.assert-is-not-validation": {
22
+ "true": 3,
23
+ "false": 12,
24
+ "precision": "3/15"
25
+ },
26
+ "odoo.ondelete-is-a-decision": {
27
+ "true": 13,
28
+ "false": 2,
29
+ "precision": "13/15"
30
+ },
31
+ "odoo.sudo-bypasses-record-rules": {
32
+ "true": 0,
33
+ "false": 15,
34
+ "precision": "0/15"
35
+ }
36
+ },
37
+ "false_classes": {
38
+ "framework-internal-invariant": 8,
39
+ "domain-scopes-it-already": 5,
40
+ "framework-unstored-magic-field": 4,
41
+ "private-helper-contract": 3,
42
+ "sql-view-has-no-foreign-key": 2,
43
+ "authentication-by-token": 2,
44
+ "cross-company-by-design": 2,
45
+ "self-check-on-module-constants": 1,
46
+ "global-uniqueness-by-design": 1,
47
+ "de-escalation": 1,
48
+ "public-website-read": 1,
49
+ "system-configuration-read": 1,
50
+ "cross-user-by-design": 1,
51
+ "guard-must-see-everything": 1
52
+ },
53
+ "intervals": "n=15 per rule, so every rate here carries a wide band. 95% Wilson intervals: stored-compute 11/15 is 48-89%, ondelete 13/15 is 60-95%, assert 3/15 is 7-45%, sudo 0/15 is 0-20%. They separate the rules into three groups and do not support finer claims than that.",
54
+ "hits": [
55
+ {
56
+ "rule": "odoo.ondelete-is-a-decision",
57
+ "path": "addons/account/models/account_move.py",
58
+ "line": 198,
59
+ "verdict": "true",
60
+ "class": null,
61
+ "why": "the construct and the harm the rule names are both present",
62
+ "still_reported_after_this_pass": true
63
+ },
64
+ {
65
+ "rule": "odoo.ondelete-is-a-decision",
66
+ "path": "addons/account/models/account_move.py",
67
+ "line": 290,
68
+ "verdict": "true",
69
+ "class": null,
70
+ "why": "the construct and the harm the rule names are both present",
71
+ "still_reported_after_this_pass": true
72
+ },
73
+ {
74
+ "rule": "odoo.ondelete-is-a-decision",
75
+ "path": "addons/account/models/account_report.py",
76
+ "line": 877,
77
+ "verdict": "true",
78
+ "class": null,
79
+ "why": "the construct and the harm the rule names are both present",
80
+ "still_reported_after_this_pass": true
81
+ },
82
+ {
83
+ "rule": "odoo.ondelete-is-a-decision",
84
+ "path": "addons/hr_contract/report/hr_contract_history.py",
85
+ "line": 25,
86
+ "verdict": "false",
87
+ "class": "sql-view-has-no-foreign-key",
88
+ "why": "hr.contract.history sets _auto = False, so there is no table, no foreign key and no ondelete to decide",
89
+ "still_reported_after_this_pass": false
90
+ },
91
+ {
92
+ "rule": "odoo.ondelete-is-a-decision",
93
+ "path": "addons/hr_expense/models/hr_expense_sheet.py",
94
+ "line": 192,
95
+ "verdict": "true",
96
+ "class": null,
97
+ "why": "the construct and the harm the rule names are both present",
98
+ "still_reported_after_this_pass": true
99
+ },
100
+ {
101
+ "rule": "odoo.ondelete-is-a-decision",
102
+ "path": "addons/hr_holidays/models/hr_leave.py",
103
+ "line": 163,
104
+ "verdict": "true",
105
+ "class": null,
106
+ "why": "the construct and the harm the rule names are both present",
107
+ "still_reported_after_this_pass": true
108
+ },
109
+ {
110
+ "rule": "odoo.ondelete-is-a-decision",
111
+ "path": "addons/l10n_it_edi_ndd/models/account_move.py",
112
+ "line": 17,
113
+ "verdict": "true",
114
+ "class": null,
115
+ "why": "the construct and the harm the rule names are both present",
116
+ "still_reported_after_this_pass": true
117
+ },
118
+ {
119
+ "rule": "odoo.ondelete-is-a-decision",
120
+ "path": "addons/mrp/models/stock_move.py",
121
+ "line": 230,
122
+ "verdict": "true",
123
+ "class": null,
124
+ "why": "the construct and the harm the rule names are both present",
125
+ "still_reported_after_this_pass": true
126
+ },
127
+ {
128
+ "rule": "odoo.ondelete-is-a-decision",
129
+ "path": "addons/purchase/models/purchase_bill_line_match.py",
130
+ "line": 16,
131
+ "verdict": "false",
132
+ "class": "sql-view-has-no-foreign-key",
133
+ "why": "purchase.bill.line.match sets _auto = False, same reason",
134
+ "still_reported_after_this_pass": false
135
+ },
136
+ {
137
+ "rule": "odoo.ondelete-is-a-decision",
138
+ "path": "addons/resource/models/resource_calendar.py",
139
+ "line": 85,
140
+ "verdict": "true",
141
+ "class": null,
142
+ "why": "the construct and the harm the rule names are both present",
143
+ "still_reported_after_this_pass": true
144
+ },
145
+ {
146
+ "rule": "odoo.ondelete-is-a-decision",
147
+ "path": "addons/sale_crm/models/sale_order.py",
148
+ "line": 10,
149
+ "verdict": "true",
150
+ "class": null,
151
+ "why": "the construct and the harm the rule names are both present",
152
+ "still_reported_after_this_pass": true
153
+ },
154
+ {
155
+ "rule": "odoo.ondelete-is-a-decision",
156
+ "path": "addons/stock/models/stock_warehouse.py",
157
+ "line": 75,
158
+ "verdict": "true",
159
+ "class": null,
160
+ "why": "the construct and the harm the rule names are both present",
161
+ "still_reported_after_this_pass": true
162
+ },
163
+ {
164
+ "rule": "odoo.ondelete-is-a-decision",
165
+ "path": "addons/stock_account/models/account_move.py",
166
+ "line": 10,
167
+ "verdict": "true",
168
+ "class": null,
169
+ "why": "the construct and the harm the rule names are both present",
170
+ "still_reported_after_this_pass": true
171
+ },
172
+ {
173
+ "rule": "odoo.ondelete-is-a-decision",
174
+ "path": "addons/stock_landed_costs/models/stock_landed_cost.py",
175
+ "line": 389,
176
+ "verdict": "true",
177
+ "class": null,
178
+ "why": "the construct and the harm the rule names are both present",
179
+ "still_reported_after_this_pass": true
180
+ },
181
+ {
182
+ "rule": "odoo.ondelete-is-a-decision",
183
+ "path": "odoo/addons/test_access_rights/models.py",
184
+ "line": 13,
185
+ "verdict": "true",
186
+ "class": null,
187
+ "why": "the construct and the harm the rule names are both present",
188
+ "still_reported_after_this_pass": true
189
+ },
190
+ {
191
+ "rule": "odoo.stored-compute-needs-complete-depends",
192
+ "path": "addons/account/models/account_move_line.py",
193
+ "line": 886,
194
+ "verdict": "true",
195
+ "class": null,
196
+ "why": "tax_ids is store=True and reads display_type, itself a store=True readonly=False field that can be edited without product_id changing",
197
+ "still_reported_after_this_pass": true
198
+ },
199
+ {
200
+ "rule": "odoo.stored-compute-needs-complete-depends",
201
+ "path": "addons/account/models/account_tax.py",
202
+ "line": 4686,
203
+ "verdict": "true",
204
+ "class": null,
205
+ "why": "declares account_id and reads account_id.internal_group, so editing an account's type leaves use_in_tax_closing on its old answer",
206
+ "still_reported_after_this_pass": true
207
+ },
208
+ {
209
+ "rule": "odoo.stored-compute-needs-complete-depends",
210
+ "path": "addons/hr_attendance/models/hr_attendance.py",
211
+ "line": 200,
212
+ "verdict": "false",
213
+ "class": "framework-unstored-magic-field",
214
+ "why": "display_name is added by odoo/models.py with no store and this file does not redeclare it",
215
+ "still_reported_after_this_pass": false
216
+ },
217
+ {
218
+ "rule": "odoo.stored-compute-needs-complete-depends",
219
+ "path": "addons/hr_recruitment/models/calendar.py",
220
+ "line": 85,
221
+ "verdict": "true",
222
+ "class": null,
223
+ "why": "declares applicant_id and reads applicant_id.candidate_id",
224
+ "still_reported_after_this_pass": true
225
+ },
226
+ {
227
+ "rule": "odoo.stored-compute-needs-complete-depends",
228
+ "path": "addons/l10n_in/models/account_move_line.py",
229
+ "line": 12,
230
+ "verdict": "true",
231
+ "class": null,
232
+ "why": "reads move_id.country_code and parent_state, declares neither, on a store=True HSN code a tax report is built from",
233
+ "still_reported_after_this_pass": true
234
+ },
235
+ {
236
+ "rule": "odoo.stored-compute-needs-complete-depends",
237
+ "path": "addons/l10n_jo_edi_extended/models/account_move.py",
238
+ "line": 28,
239
+ "verdict": "true",
240
+ "class": null,
241
+ "why": "reads commercial_partner_id, declares only partner_id.country_code",
242
+ "still_reported_after_this_pass": true
243
+ },
244
+ {
245
+ "rule": "odoo.stored-compute-needs-complete-depends",
246
+ "path": "addons/l10n_tw_edi_ecpay/models/account_move.py",
247
+ "line": 229,
248
+ "verdict": "true",
249
+ "class": null,
250
+ "why": "declares partner_id and reads partner_id.vat",
251
+ "still_reported_after_this_pass": true
252
+ },
253
+ {
254
+ "rule": "odoo.stored-compute-needs-complete-depends",
255
+ "path": "addons/lunch/models/lunch_supplier.py",
256
+ "line": 127,
257
+ "verdict": "false",
258
+ "class": "framework-unstored-magic-field",
259
+ "why": "display_name again, not redeclared here",
260
+ "still_reported_after_this_pass": false
261
+ },
262
+ {
263
+ "rule": "odoo.stored-compute-needs-complete-depends",
264
+ "path": "addons/mrp/models/mrp_production.py",
265
+ "line": 436,
266
+ "verdict": "true",
267
+ "class": null,
268
+ "why": "declares bom_id and reads bom_id.product_qty",
269
+ "still_reported_after_this_pass": true
270
+ },
271
+ {
272
+ "rule": "odoo.stored-compute-needs-complete-depends",
273
+ "path": "addons/mrp_subcontracting/models/stock_picking.py",
274
+ "line": 44,
275
+ "verdict": "true",
276
+ "class": null,
277
+ "why": "reads picking_type_id.warehouse_id, declares only picking_type_id",
278
+ "still_reported_after_this_pass": true
279
+ },
280
+ {
281
+ "rule": "odoo.stored-compute-needs-complete-depends",
282
+ "path": "addons/sale/models/sale_order_line.py",
283
+ "line": 503,
284
+ "verdict": "true",
285
+ "class": null,
286
+ "why": "reads product_packaging_id.qty, declares product_packaging_qty",
287
+ "still_reported_after_this_pass": true
288
+ },
289
+ {
290
+ "rule": "odoo.stored-compute-needs-complete-depends",
291
+ "path": "addons/sale_stock/models/sale_order_line.py",
292
+ "line": 39,
293
+ "verdict": "true",
294
+ "class": null,
295
+ "why": "reads order_id.partner_shipping_id, declared nowhere",
296
+ "still_reported_after_this_pass": true
297
+ },
298
+ {
299
+ "rule": "odoo.stored-compute-needs-complete-depends",
300
+ "path": "addons/stock/models/stock_picking.py",
301
+ "line": 720,
302
+ "verdict": "true",
303
+ "class": null,
304
+ "why": "reads group_id, declared nowhere, on a store=True move_type",
305
+ "still_reported_after_this_pass": true
306
+ },
307
+ {
308
+ "rule": "odoo.stored-compute-needs-complete-depends",
309
+ "path": "addons/stock/models/stock_quant.py",
310
+ "line": 595,
311
+ "verdict": "false",
312
+ "class": "framework-unstored-magic-field",
313
+ "why": "display_name again, not redeclared here",
314
+ "still_reported_after_this_pass": false
315
+ },
316
+ {
317
+ "rule": "odoo.stored-compute-needs-complete-depends",
318
+ "path": "odoo/addons/base/models/res_bank.py",
319
+ "line": 121,
320
+ "verdict": "false",
321
+ "class": "framework-unstored-magic-field",
322
+ "why": "display_name again, not redeclared here",
323
+ "still_reported_after_this_pass": false
324
+ },
325
+ {
326
+ "rule": "odoo.sudo-bypasses-record-rules",
327
+ "path": "addons/account/models/account_move.py",
328
+ "line": 3730,
329
+ "verdict": "false",
330
+ "class": "domain-scopes-it-already",
331
+ "why": "the domain pins journal_id and id, and journals are company-scoped, so the search cannot reach another company's moves",
332
+ "still_reported_after_this_pass": true
333
+ },
334
+ {
335
+ "rule": "odoo.sudo-bypasses-record-rules",
336
+ "path": "addons/calendar/controllers/main.py",
337
+ "line": 107,
338
+ "verdict": "false",
339
+ "class": "authentication-by-token",
340
+ "why": "a public route resolving an access_token before any identity exists; record rules cannot apply to a visitor",
341
+ "still_reported_after_this_pass": true
342
+ },
343
+ {
344
+ "rule": "odoo.sudo-bypasses-record-rules",
345
+ "path": "addons/event_sale/models/sale_order.py",
346
+ "line": 21,
347
+ "verdict": "false",
348
+ "class": "domain-scopes-it-already",
349
+ "why": "registrations of the caller's own sale orders, by id",
350
+ "still_reported_after_this_pass": true
351
+ },
352
+ {
353
+ "rule": "odoo.sudo-bypasses-record-rules",
354
+ "path": "addons/l10n_my_edi_pos/models/myinvois_document.py",
355
+ "line": 213,
356
+ "verdict": "false",
357
+ "class": "domain-scopes-it-already",
358
+ "why": "the same sequence-reference lookup as 1, pinned by id and name",
359
+ "still_reported_after_this_pass": true
360
+ },
361
+ {
362
+ "rule": "odoo.sudo-bypasses-record-rules",
363
+ "path": "addons/payment/models/payment_transaction.py",
364
+ "line": 361,
365
+ "verdict": "false",
366
+ "class": "global-uniqueness-by-design",
367
+ "why": "payment references must be unique across the database, so the prefix scan has to see rows the caller cannot",
368
+ "still_reported_after_this_pass": true
369
+ },
370
+ {
371
+ "rule": "odoo.sudo-bypasses-record-rules",
372
+ "path": "addons/point_of_sale/models/account_journal.py",
373
+ "line": 16,
374
+ "verdict": "false",
375
+ "class": "domain-scopes-it-already",
376
+ "why": "payment methods of the caller's own journals, by id",
377
+ "still_reported_after_this_pass": true
378
+ },
379
+ {
380
+ "rule": "odoo.sudo-bypasses-record-rules",
381
+ "path": "addons/point_of_sale/models/account_tax.py",
382
+ "line": 22,
383
+ "verdict": "false",
384
+ "class": "domain-scopes-it-already",
385
+ "why": "pinned to ids the caller already filtered out of self",
386
+ "still_reported_after_this_pass": true
387
+ },
388
+ {
389
+ "rule": "odoo.sudo-bypasses-record-rules",
390
+ "path": "addons/sale/wizard/sale_make_invoice_advance.py",
391
+ "line": 208,
392
+ "verdict": "false",
393
+ "class": "de-escalation",
394
+ "why": "invoice.sudo(self.env.su) restores the ambient level; the comment reads 'Unsudo the invoice after creation if not already sudoed'. A checker defect, fixed",
395
+ "still_reported_after_this_pass": false
396
+ },
397
+ {
398
+ "rule": "odoo.sudo-bypasses-record-rules",
399
+ "path": "addons/sale_management/models/sale_order_template.py",
400
+ "line": 146,
401
+ "verdict": "false",
402
+ "class": "cross-company-by-design",
403
+ "why": "archiving a shared template must clear it from every company that points at it, or the references dangle",
404
+ "still_reported_after_this_pass": true
405
+ },
406
+ {
407
+ "rule": "odoo.sudo-bypasses-record-rules",
408
+ "path": "addons/survey/controllers/survey_session_manage.py",
409
+ "line": 26,
410
+ "verdict": "false",
411
+ "class": "authentication-by-token",
412
+ "why": "a public survey session code resolved before identity",
413
+ "still_reported_after_this_pass": true
414
+ },
415
+ {
416
+ "rule": "odoo.sudo-bypasses-record-rules",
417
+ "path": "addons/website_event_track/controllers/event_track.py",
418
+ "line": 368,
419
+ "verdict": "false",
420
+ "class": "public-website-read",
421
+ "why": "a website controller rendering a published track to anonymous visitors",
422
+ "still_reported_after_this_pass": true
423
+ },
424
+ {
425
+ "rule": "odoo.sudo-bypasses-record-rules",
426
+ "path": "odoo/addons/base/models/ir_mail_server.py",
427
+ "line": 792,
428
+ "verdict": "false",
429
+ "class": "system-configuration-read",
430
+ "why": "listing outgoing mail servers to pick one; sending mail does not require read access to ir.mail_server",
431
+ "still_reported_after_this_pass": true
432
+ },
433
+ {
434
+ "rule": "odoo.sudo-bypasses-record-rules",
435
+ "path": "odoo/addons/base/models/ir_ui_view.py",
436
+ "line": 549,
437
+ "verdict": "false",
438
+ "class": "cross-user-by-design",
439
+ "why": "drops other users' view customisations when the view changes, which the comment beside it states as the intent",
440
+ "still_reported_after_this_pass": true
441
+ },
442
+ {
443
+ "rule": "odoo.sudo-bypasses-record-rules",
444
+ "path": "odoo/addons/base/models/res_partner.py",
445
+ "line": 667,
446
+ "verdict": "false",
447
+ "class": "cross-company-by-design",
448
+ "why": "iterating every company is what _company_dependent_commercial_sync is for",
449
+ "still_reported_after_this_pass": true
450
+ },
451
+ {
452
+ "rule": "odoo.sudo-bypasses-record-rules",
453
+ "path": "odoo/addons/base/models/res_partner.py",
454
+ "line": 827,
455
+ "verdict": "false",
456
+ "class": "guard-must-see-everything",
457
+ "why": "an @api.ondelete guard checking whether any user links to the partner; a scoped search would let the guard be bypassed",
458
+ "still_reported_after_this_pass": true
459
+ },
460
+ {
461
+ "rule": "python.assert-is-not-validation",
462
+ "path": "addons/base_import_module/models/ir_module.py",
463
+ "line": 111,
464
+ "verdict": "true",
465
+ "class": null,
466
+ "why": "guards 'installable' read out of the manifest of a module being imported from an uploaded zip; under -O the gate disappears and a module marked not installable installs",
467
+ "still_reported_after_this_pass": true
468
+ },
469
+ {
470
+ "rule": "python.assert-is-not-validation",
471
+ "path": "addons/delivery/models/sale_order.py",
472
+ "line": 100,
473
+ "verdict": "false",
474
+ "class": "private-helper-contract",
475
+ "why": "_get_pickup_locations is private and the docstring states the contract its own module's callers keep",
476
+ "still_reported_after_this_pass": true
477
+ },
478
+ {
479
+ "rule": "python.assert-is-not-validation",
480
+ "path": "addons/mail/tools/discuss.py",
481
+ "line": 147,
482
+ "verdict": "false",
483
+ "class": "private-helper-contract",
484
+ "why": "_add_values is a private Store helper; the key comes from callers inside the module",
485
+ "still_reported_after_this_pass": true
486
+ },
487
+ {
488
+ "rule": "python.assert-is-not-validation",
489
+ "path": "odoo/_monkeypatches/num2words.py",
490
+ "line": 391,
491
+ "verdict": "false",
492
+ "class": "self-check-on-module-constants",
493
+ "why": "asserts two hardcoded lists are the same length; no caller can make it false",
494
+ "still_reported_after_this_pass": true
495
+ },
496
+ {
497
+ "rule": "python.assert-is-not-validation",
498
+ "path": "odoo/addons/base/models/ir_mail_server.py",
499
+ "line": 666,
500
+ "verdict": "true",
501
+ "class": null,
502
+ "why": "the recipient list is derived from addresses handed to a public send_email, and the assert is what stops a send with no valid recipient",
503
+ "still_reported_after_this_pass": true
504
+ },
505
+ {
506
+ "rule": "python.assert-is-not-validation",
507
+ "path": "odoo/addons/base/models/ir_model.py",
508
+ "line": 2098,
509
+ "verdict": "true",
510
+ "class": null,
511
+ "why": "access_mode is interpolated into SQL as perm_{access_mode} on a public, RPC-callable method, and this assert is the only thing constraining it to four literals",
512
+ "still_reported_after_this_pass": true
513
+ },
514
+ {
515
+ "rule": "python.assert-is-not-validation",
516
+ "path": "odoo/fields.py",
517
+ "line": 2653,
518
+ "verdict": "false",
519
+ "class": "framework-internal-invariant",
520
+ "why": "a field descriptor asserting its own configuration",
521
+ "still_reported_after_this_pass": true
522
+ },
523
+ {
524
+ "rule": "python.assert-is-not-validation",
525
+ "path": "odoo/fields.py",
526
+ "line": 2899,
527
+ "verdict": "false",
528
+ "class": "framework-internal-invariant",
529
+ "why": "registry setup asserting a Selection has a selection; developer error at boot",
530
+ "still_reported_after_this_pass": true
531
+ },
532
+ {
533
+ "rule": "python.assert-is-not-validation",
534
+ "path": "odoo/models.py",
535
+ "line": 241,
536
+ "verdict": "false",
537
+ "class": "framework-internal-invariant",
538
+ "why": "asserts a model class is imported from odoo.addons; developer error at class definition",
539
+ "still_reported_after_this_pass": true
540
+ },
541
+ {
542
+ "rule": "python.assert-is-not-validation",
543
+ "path": "odoo/modules/registry.py",
544
+ "line": 783,
545
+ "verdict": "false",
546
+ "class": "framework-internal-invariant",
547
+ "why": "Registry.clear_cache asserting its own cache-name convention",
548
+ "still_reported_after_this_pass": true
549
+ },
550
+ {
551
+ "rule": "python.assert-is-not-validation",
552
+ "path": "odoo/modules/registry.py",
553
+ "line": 875,
554
+ "verdict": "false",
555
+ "class": "framework-internal-invariant",
556
+ "why": "asserts the cursor is not the read-only replica",
557
+ "still_reported_after_this_pass": true
558
+ },
559
+ {
560
+ "rule": "python.assert-is-not-validation",
561
+ "path": "odoo/tools/pycompat.py",
562
+ "line": 15,
563
+ "verdict": "false",
564
+ "class": "private-helper-contract",
565
+ "why": "a deprecated compatibility shim asserting the stream type its callers pass",
566
+ "still_reported_after_this_pass": true
567
+ },
568
+ {
569
+ "rule": "python.assert-is-not-validation",
570
+ "path": "odoo/tools/query.py",
571
+ "line": 124,
572
+ "verdict": "false",
573
+ "class": "framework-internal-invariant",
574
+ "why": "the Query builder asserting an alias it generated itself is known",
575
+ "still_reported_after_this_pass": true
576
+ },
577
+ {
578
+ "rule": "python.assert-is-not-validation",
579
+ "path": "odoo/tools/sql.py",
580
+ "line": 668,
581
+ "verdict": "false",
582
+ "class": "framework-internal-invariant",
583
+ "why": "asserts the named fields are integers; the names reach SQL only through SQL.identifier, which escapes them",
584
+ "still_reported_after_this_pass": true
585
+ },
586
+ {
587
+ "rule": "python.assert-is-not-validation",
588
+ "path": "odoo/tools/translate.py",
589
+ "line": 633,
590
+ "verdict": "false",
591
+ "class": "framework-internal-invariant",
592
+ "why": "a translation helper asserting its own argument convention",
593
+ "still_reported_after_this_pass": true
594
+ }
595
+ ],
596
+ "note": "The headline is that precision depends on what KIND of code is under review, and this is the first tree here that made the difference legible. assert-is-not-validation measured 6 true / 4 false on OCA addons and 3 true / 12 false here. The rule's own true class is 'the asserted value came from outside the module', and a framework's asserts are overwhelmingly invariants between its own components -- the registry, the field descriptors, the query builder -- where there is no outside. Same rule, same code, different subject.\n\nsudo-bypasses-record-rules measured 0 of 15 on core against 6 true on OpenSPP. Every core finding is a deliberate framework elevation: a public route resolving a token before any identity exists, a delete guard that has to see records the caller cannot, a cross-company sync whose entire purpose is to cross companies. The rule is about an application leaking between tenants, and core is the thing that implements tenancy.",
597
+ "worklist": "One finding in the assert sample is worth acting on outside this project: odoo core's ir.model.access.group_names_with_access interpolates its access_mode argument straight into SQL as perm_{access_mode}, and the only thing constraining it to four literals is an assert. The method is public and RPC-callable. Odoo does not run under python -O, so this is latent rather than live, and the assert also turns a bad argument into a 500 rather than a clean error.\n\nStill unread on core: roughly 1,590 findings, led by naive-and-aware-datetimes (167), constrains-must-list-every-field-read (151) and depends-must-not-name-unread-fields (134)."
598
+ }
classification-sudo-2026-09.json ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "generated_utc": "2026-09-21T12:02:21+00:00",
3
+ "stack": "odoo",
4
+ "checkouts": {
5
+ "OpenSPP2-19.0": "cfa0dc8ee3432ab2dd095a4882a861cc9dfba039",
6
+ "server-tools": "60cd54e5ee8f6538a14266af8a7b3d1da0af08ec",
7
+ "account-financial-tools": "e4c1b86aa0a61d9f484c1a7e9830a63f5e6a2233"
8
+ },
9
+ "scope": "every live finding odoo.sudo-bypasses-record-rules makes on both trees after the rule grew the two clauses its own sentence asks for -- a write or read on a record under sudo, and a sudo recordset passed onward -- read by hand against the file it sits in. Findings on a line already carrying another analyser's suppression marker are excluded, as they are from every rate in this project. The six query-clause findings on OCA were classified in classification-oca-2026-09.json and are not re-litigated here.",
10
+ "lines": 260897,
11
+ "totals": {
12
+ "false": 24
13
+ },
14
+ "by_clause": {
15
+ "query": {
16
+ "true": 0,
17
+ "false": 3
18
+ },
19
+ "touch": {
20
+ "true": 0,
21
+ "false": 21
22
+ },
23
+ "rebind": {
24
+ "true_in_history": 1,
25
+ "false_at_head": 1,
26
+ "note": "server-tools cbefaa511485 is an on-target recall catch; the one live finding at HEAD is false"
27
+ }
28
+ },
29
+ "false_classes": {
30
+ "touches-a-record-already-held": 7,
31
+ "demo-seeder": 4,
32
+ "framework-metadata-read": 2,
33
+ "writes-the-callers-own-company": 2,
34
+ "configuration-model-create": 2,
35
+ "global-uniqueness-by-design": 1,
36
+ "authentication-before-identity": 1,
37
+ "framework-metadata-create": 1,
38
+ "dba-configuration-model": 1,
39
+ "rebind-over-metadata-only": 1,
40
+ "company-refiltered-outside-the-call": 1,
41
+ "scoped-to-a-record-already-held": 1
42
+ },
43
+ "hits": [
44
+ {
45
+ "rule": "odoo.sudo-bypasses-record-rules",
46
+ "path": "account-financial-tools/account_asset_management/models/account_asset.py",
47
+ "line": 1233,
48
+ "clause": "touch",
49
+ "likelihood": "unproven",
50
+ "verdict": "false",
51
+ "class": "company-refiltered-outside-the-call",
52
+ "why": "triggers is recomputes filtered on company_id.id in companies.ids one line above, which is the rule's own advice followed where the checker reads only the call"
53
+ },
54
+ {
55
+ "rule": "odoo.sudo-bypasses-record-rules",
56
+ "path": "account-financial-tools/account_asset_management/models/account_move.py",
57
+ "line": 157,
58
+ "clause": "touch",
59
+ "likelihood": "unproven",
60
+ "verdict": "false",
61
+ "class": "framework-metadata-read",
62
+ "why": "reads an ir.actions.act_window fetched by env.ref; no tenant boundary"
63
+ },
64
+ {
65
+ "rule": "odoo.sudo-bypasses-record-rules",
66
+ "path": "account-financial-tools/account_journal_general_sequence/wizards/account_move_renumber_wizard.py",
67
+ "line": 85,
68
+ "clause": "touch",
69
+ "likelihood": "unproven",
70
+ "verdict": "false",
71
+ "class": "scoped-to-a-record-already-held",
72
+ "why": "the date ranges belong to the one sequence the wizard holds, and the wizard is ACL-gated to accounting managers by its own comment and its access rules"
73
+ },
74
+ {
75
+ "rule": "odoo.sudo-bypasses-record-rules",
76
+ "path": "account-financial-tools/account_lock_date_update/wizards/account_update_lock_date.py",
77
+ "line": 82,
78
+ "clause": "touch",
79
+ "likelihood": "unproven",
80
+ "verdict": "false",
81
+ "class": "writes-the-callers-own-company",
82
+ "why": "writes lock dates onto self.company_id after _check_execute_allowed has required group_account_manager"
83
+ },
84
+ {
85
+ "rule": "odoo.sudo-bypasses-record-rules",
86
+ "path": "account-financial-tools/account_lock_to_date/wizards/account_update_lock_to_date.py",
87
+ "line": 61,
88
+ "clause": "touch",
89
+ "likelihood": "unproven",
90
+ "verdict": "false",
91
+ "class": "writes-the-callers-own-company",
92
+ "why": "the same wizard for the to-date variant, behind the same group check"
93
+ },
94
+ {
95
+ "rule": "odoo.sudo-bypasses-record-rules",
96
+ "path": "account-financial-tools/account_move_line_purchase_info/models/purchase_order.py",
97
+ "line": 30,
98
+ "clause": "touch",
99
+ "likelihood": "unproven",
100
+ "verdict": "false",
101
+ "class": "touches-a-record-already-held",
102
+ "why": "self.sudo().read() prefetches one field on the records the caller already has, and the result is discarded"
103
+ },
104
+ {
105
+ "rule": "odoo.sudo-bypasses-record-rules",
106
+ "path": "account-financial-tools/account_move_name_sequence/models/account_journal.py",
107
+ "line": 111,
108
+ "clause": "touch",
109
+ "likelihood": "unproven",
110
+ "verdict": "false",
111
+ "class": "configuration-model-create",
112
+ "why": "creates the ir.sequence a journal needs, from vals prepared off that journal, after a non-sudo search found none"
113
+ },
114
+ {
115
+ "rule": "odoo.sudo-bypasses-record-rules",
116
+ "path": "account-financial-tools/account_move_name_sequence/models/ir_sequence.py",
117
+ "line": 51,
118
+ "clause": "touch",
119
+ "likelihood": "unproven",
120
+ "verdict": "false",
121
+ "class": "configuration-model-create",
122
+ "why": "a date range for the sequence the caller holds, keyed by self.id"
123
+ },
124
+ {
125
+ "rule": "odoo.sudo-bypasses-record-rules",
126
+ "path": "endpoint_route_handler/models/endpoint_route_handler.py",
127
+ "line": 65,
128
+ "clause": "query",
129
+ "likelihood": "defect",
130
+ "verdict": "false",
131
+ "class": "global-uniqueness-by-design",
132
+ "why": "a route is registered once for the whole database, so the clash check has to see every company's rows; scoping it would break the thing it exists to do"
133
+ },
134
+ {
135
+ "rule": "odoo.sudo-bypasses-record-rules",
136
+ "path": "fastapi/models/fastapi_endpoint_demo.py",
137
+ "line": 89,
138
+ "clause": "query",
139
+ "likelihood": "defect",
140
+ "verdict": "false",
141
+ "class": "authentication-before-identity",
142
+ "why": "an api key is resolved to a user before any identity exists, so there is no caller whose record rules could have applied"
143
+ },
144
+ {
145
+ "rule": "odoo.sudo-bypasses-record-rules",
146
+ "path": "server-tools/auditlog/models/rule.py",
147
+ "line": 827,
148
+ "clause": "touch",
149
+ "likelihood": "unproven",
150
+ "verdict": "false",
151
+ "class": "framework-metadata-create",
152
+ "why": "creates the ir.actions.act_window that surfaces an audit log; a window action has no tenant boundary"
153
+ },
154
+ {
155
+ "rule": "odoo.sudo-bypasses-record-rules",
156
+ "path": "server-tools/database_autovacuum_tuning/models/database_autovacuum_tuning.py",
157
+ "line": 34,
158
+ "clause": "touch",
159
+ "likelihood": "unproven",
160
+ "verdict": "false",
161
+ "class": "dba-configuration-model",
162
+ "why": "a postgres tuning row keyed by schema and table name, on a model with no tenant boundary at all"
163
+ },
164
+ {
165
+ "rule": "odoo.sudo-bypasses-record-rules",
166
+ "path": "server-tools/tracking_manager/models/ir_model.py",
167
+ "line": 58,
168
+ "clause": "query",
169
+ "likelihood": "defect",
170
+ "verdict": "false",
171
+ "class": "rebind-over-metadata-only",
172
+ "why": "`self = self.sudo()` is the construct exactly, and what the method then reads is ir.model.fields and ir.model -- framework metadata with no tenant boundary. The latent half of the rule is real here (anything added below that line is silently elevated too) but nothing below it today crosses a boundary"
173
+ },
174
+ {
175
+ "rule": "odoo.sudo-bypasses-record-rules",
176
+ "path": "spp_case_demo/models/generate_cases.py",
177
+ "line": 233,
178
+ "clause": "touch",
179
+ "likelihood": "unproven",
180
+ "verdict": "false",
181
+ "class": "demo-seeder",
182
+ "why": "a demo wizard creating an intervention under the plan it created a few lines earlier; the run is an administrator seeding a database and has no caller to isolate from"
183
+ },
184
+ {
185
+ "rule": "odoo.sudo-bypasses-record-rules",
186
+ "path": "spp_case_demo/models/generate_cases.py",
187
+ "line": 252,
188
+ "clause": "touch",
189
+ "likelihood": "unproven",
190
+ "verdict": "false",
191
+ "class": "touches-a-record-already-held",
192
+ "why": "intervention came from a non-sudo search two lines above, so the caller could already see it and sudo only elevates the write"
193
+ },
194
+ {
195
+ "rule": "odoo.sudo-bypasses-record-rules",
196
+ "path": "spp_case_demo/models/generate_cases.py",
197
+ "line": 308,
198
+ "clause": "touch",
199
+ "likelihood": "unproven",
200
+ "verdict": "false",
201
+ "class": "touches-a-record-already-held",
202
+ "why": "case was created by this same wizard in this same call"
203
+ },
204
+ {
205
+ "rule": "odoo.sudo-bypasses-record-rules",
206
+ "path": "spp_case_demo/models/generate_cases.py",
207
+ "line": 411,
208
+ "clause": "touch",
209
+ "likelihood": "unproven",
210
+ "verdict": "false",
211
+ "class": "demo-seeder",
212
+ "why": "interventions attached to the plan the wizard just created"
213
+ },
214
+ {
215
+ "rule": "odoo.sudo-bypasses-record-rules",
216
+ "path": "spp_case_demo/models/generate_cases.py",
217
+ "line": 466,
218
+ "clause": "touch",
219
+ "likelihood": "unproven",
220
+ "verdict": "false",
221
+ "class": "touches-a-record-already-held",
222
+ "why": "case was created by this same wizard in this same call"
223
+ },
224
+ {
225
+ "rule": "odoo.sudo-bypasses-record-rules",
226
+ "path": "spp_case_demo/models/generate_cases.py",
227
+ "line": 556,
228
+ "clause": "touch",
229
+ "likelihood": "unproven",
230
+ "verdict": "false",
231
+ "class": "demo-seeder",
232
+ "why": "creates a referral service after a non-sudo search failed to find one; spp.service carries no rule the create could land outside of"
233
+ },
234
+ {
235
+ "rule": "odoo.sudo-bypasses-record-rules",
236
+ "path": "spp_grm_demo/models/generate_tickets.py",
237
+ "line": 558,
238
+ "clause": "touch",
239
+ "likelihood": "unproven",
240
+ "verdict": "false",
241
+ "class": "touches-a-record-already-held",
242
+ "why": "ticket was created by this same wizard in this same call"
243
+ },
244
+ {
245
+ "rule": "odoo.sudo-bypasses-record-rules",
246
+ "path": "spp_grm_demo/models/generate_tickets.py",
247
+ "line": 781,
248
+ "clause": "touch",
249
+ "likelihood": "unproven",
250
+ "verdict": "false",
251
+ "class": "touches-a-record-already-held",
252
+ "why": "ticket was created by this same wizard in this same call"
253
+ },
254
+ {
255
+ "rule": "odoo.sudo-bypasses-record-rules",
256
+ "path": "spp_grm_demo/models/generate_tickets.py",
257
+ "line": 799,
258
+ "clause": "touch",
259
+ "likelihood": "unproven",
260
+ "verdict": "false",
261
+ "class": "touches-a-record-already-held",
262
+ "why": "assigns the ticket it created to a user drawn from a non-sudo search"
263
+ },
264
+ {
265
+ "rule": "odoo.sudo-bypasses-record-rules",
266
+ "path": "spp_mis_demo_v2/models/mis_demo_generator.py",
267
+ "line": 3147,
268
+ "clause": "touch",
269
+ "likelihood": "unproven",
270
+ "verdict": "false",
271
+ "class": "demo-seeder",
272
+ "why": "an exception fallback in a demo wizard stamping the approval flags that action_apply failed to set on the record it is iterating"
273
+ },
274
+ {
275
+ "rule": "odoo.sudo-bypasses-record-rules",
276
+ "path": "spp_mis_demo_v2/models/mis_demo_generator.py",
277
+ "line": 3890,
278
+ "clause": "touch",
279
+ "likelihood": "unproven",
280
+ "verdict": "false",
281
+ "class": "framework-metadata-read",
282
+ "why": "reads an ir.actions.act_window fetched by env.ref to return it as the wizard's next action; a window action has no tenant boundary and is routinely unreadable to an ordinary user"
283
+ }
284
+ ],
285
+ "note": "0 true of 24. The query clause -- search, search_count, search_read, read_group -- keeps the 6 true positives classified in classification-openspp-rest-2026-09.json and is unchanged by this pass. The touch clause (create, write, unlink, copy, read) and the escape clause (a sudo recordset bound to a name and passed on) are new, and in 260,897 lines neither has met a real one at HEAD. Every finding they made is code where the caller is an administrator by construction -- a demo-data seeder, an ACL-gated accounting wizard -- or a framework model with no tenant boundary. So those two clauses emit likelihood 'unproven' per finding, which makes them advisory: reported, out of the rate, gating nothing. The grade travels with the finding, so the query clause keeps the rule's 'defect'.",
286
+ "worklist": "touches-a-record-already-held is 7 of the 23, and scoped-to-a-record-already-held is an eighth of the same shape. One predicate: a recordset the caller obtained under its own rules, then written under sudo. It is not nothing -- ir.rule governs write permission as well as visibility -- but it is a different claim from the one the rule's why paragraph makes, which is about a search returning another company's rows. Deciding whether that is a second rule or a narrowing wants a tree where it fires on production code, and neither of these two is that tree."
287
+ }
corpus/build_corpus.py CHANGED
@@ -59,9 +59,10 @@ def _onchange_qty(self):
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 layer.
63
- "violation_pattern": r"@api\.onchange",
64
- "satisfied_pattern": r"@api\.constrains|_sql_constraints|models\.Constraint",
 
65
  },
66
  {
67
  "id": "odoo.constrains-must-list-every-field-read",
@@ -638,19 +639,23 @@ ENRICH = {
638
  # field from a provider and narrowing a domain are what onchange is for,
639
  # and flagging those fired on all 16 onchange declarations in two OCA
640
  # repositories, none of which was validating anything.
641
- "violation_pattern": (r"@api\.onchange[\s\S]{0,400}?"
642
- r"(?:raise\s+\w*(?:Validation|User)Error"
643
- r"|['\"]warning['\"]\s*:)"),
644
- # All three spellings, including the legacy one. Dropping
645
- # _sql_constraints here was tried and reverted: it IS a real
646
- # constraint on 16 through 18, a satisfied_pattern is a plain regex
647
- # with no way to read the module's series, and the first field test
648
- # after the change produced a false positive on OCA's account_loan --
649
- # branch 18.0, a genuine _sql_constraints block, an onchange properly
650
- # backed by it. The 19 hole is left open here on purpose and closed
651
- # where the series IS legible: prefer_sql_constraint_applies reads
652
- # odoo_series off the tree facts and only tightens when it says 19+.
653
- "satisfied_pattern": r"@api\.constrains|_sql_constraints|models\.Constraint",
 
 
 
 
654
  "source": ODOO_ONCHANGE,
655
  },
656
  "odoo.constrains-ignores-dotted-names": {
@@ -671,10 +676,16 @@ ENRICH = {
671
  },
672
  "odoo.float-comparison-needs-float-utils": {
673
  # Equality only. Ordering comparisons against zero (qty > 0) have bounded
674
- # error and are usually fine; '== 0' is the trap, and this also stops the
675
- # pattern firing on CHECK(qty > 0), where float_compare does not apply.
676
- "violation_pattern": r"(?:price|amount|qty|quantity|total|balance)\w*\s*(?:==|!=)\s*0(?:\.0+)?\b",
677
- "satisfied_pattern": r"float_is_zero|float_compare",
 
 
 
 
 
 
678
  "source": ODOO_CONSTRAINTS,
679
  },
680
  "odoo.prefer-sql-constraint": {
@@ -728,7 +739,7 @@ ENRICH = {
728
  # were the defect: the other 100 were on models the tree gives no
729
  # filtering rule, where sudo drops nothing. The AST pass asks the tree
730
  # the second half of the question.
731
- "semantic_check": "sudo_search_drops_a_real_rule",
732
  # Record rules are a security concept, not an ORM one. The ORM reference
733
  # does not mention sudo at all.
734
  "source": ODOO_SECURITY,
@@ -825,12 +836,13 @@ ENRICH = {
825
  "source": PY_ERRORS,
826
  },
827
  "python.eval-and-pickle-execute-their-input": {
828
- # 0 true, 1 false. The one finding is the word eval() written in a
829
- # method's DOCSTRING, in a sentence arguing against using it. That is
830
- # not this rule's fault: scan_source strips # comments before matching
831
- # and does not strip string literals, so prose in a docstring is
832
- # matched as code. One sample decides nothing about the rule, and the
833
- # checker defect it exposes is named in the README.
 
834
  "violation_pattern": r"\beval\(|\bexec\(|pickle\.loads?\(",
835
  "source": PY_PICKLE,
836
  },
@@ -842,43 +854,37 @@ ENRICH = {
842
  "source": PY_SUBPROCESS,
843
  },
844
  "python.path-join-does-not-contain-a-path": {
845
- # 0 true, 4 false. All four join a configured directory to a name this
846
- # module generated -- a timestamped backup filename, a literal from a
847
- # list declared in the same function, a module path from
848
- # get_module_path. The name-matching is what fires: `filename` is in
849
- # the alternation and says nothing about where the value came from.
850
- # Four findings is not enough to conclude a checker is wrong, so it is
851
- # reported here rather than acted on.
852
  #
853
- # Word-anchored. Unanchored, `form` matched inside `.format(`, so
854
- # every os.path.join(..., "x {0}".format(y)) in a codebase was a hit.
855
- "violation_pattern": (r"os\.path\.join\([^)]*"
856
- r"\b(?:request|args|params|payload|form|"
857
- r"user_input|uploaded|filename)\b"),
858
- # Cleared by resolving and comparing, which is the fix. Without this the
859
- # rule's own correct sample matches its own violation pattern: the fix
860
- # still joins, it just checks the result afterwards.
861
- "satisfied_pattern": r"commonpath|commonprefix|relpath|is_relative_to",
862
  "source": PY_OSPATH,
863
  },
864
  "python.decimal-for-money-not-float": {
865
- # 0 true, 3 false, and this one has a reason behind it rather than a
866
  # sample size. Odoo's Monetary field IS a float -- the ORM defines it
867
  # that way -- and the framework's answer to the precision problem is
868
  # float_round and float_compare against the currency's rounding, not
869
- # Decimal. So inside an Odoo module the rule fires on the idiom and its
870
- # remedy cannot be taken. That is an argument, not a measurement, and
871
- # three findings do not settle it either way; it is written down in the
872
- # README as the composition's open question rather than acted on here.
873
  #
874
- # Money, not arithmetic. A bare \bfloat\( caught `float(successes) /
875
- # total` inside a Wilson score interval, which is exactly what binary
876
- # floating point is for. The name has to say it is an amount.
877
- "violation_pattern": (r"(?:price|amount|total|cost|balance|fee|tax|"
878
- r"subtotal|payment|salary)\w*\s*=\s*float\(|"
879
- r"float\(\s*[^)]*\b(?:price|amount|cost|balance|"
880
- r"fee|tax|subtotal|payment|salary)\b"),
881
- "satisfied_pattern": r"Decimal",
 
882
  "source": PY_DECIMAL,
883
  },
884
  "python.naive-and-aware-datetimes-do-not-compare": {
@@ -969,6 +975,75 @@ LIKELIHOOD = {
969
  # construct is a real defect; nothing measured here has met one. See the
970
  # checker's docstring for how the rule came about and which half was wrong.
971
  "odoo.except-order-follows-the-hierarchy": "unproven",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972
  }
973
 
974
  # --------------------------------------------------------------------- PROBES
 
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
63
+ # layer. Both halves of that -- what counts as trying to enforce, and what
64
+ # counts as a real layer -- are in onchange_tries_to_enforce, which is where
65
+ # they can be scoped to the decorated function.
66
  },
67
  {
68
  "id": "odoo.constrains-must-list-every-field-read",
 
639
  # field from a provider and narrowing a domain are what onchange is for,
640
  # and flagging those fired on all 16 onchange declarations in two OCA
641
  # repositories, none of which was validating anything.
642
+ #
643
+ # Was a regex reading 400 characters after the decorator, which is not
644
+ # a function and could not see where one ended. All three of its false
645
+ # positives were the same failure: a short onchange that assigns a
646
+ # field, followed by an action method that raises, with the window
647
+ # crossing the boundary between them. The AST pass scopes the question
648
+ # to the decorated function.
649
+ #
650
+ # The satisfied_pattern moved inside the checker with the same three
651
+ # spellings, including the legacy one. Dropping _sql_constraints was
652
+ # tried and reverted: it IS a real constraint on 16 through 18, and the
653
+ # first field test after the change produced a false positive on OCA's
654
+ # account_loan -- branch 18.0, a genuine _sql_constraints block, an
655
+ # onchange properly backed by it. The 19 hole is left open on purpose
656
+ # and closed where the series IS legible: prefer_sql_constraint_applies
657
+ # reads odoo_series off the tree facts and only tightens when it says 19+.
658
+ "semantic_check": "onchange_tries_to_enforce",
659
  "source": ODOO_ONCHANGE,
660
  },
661
  "odoo.constrains-ignores-dotted-names": {
 
676
  },
677
  "odoo.float-comparison-needs-float-utils": {
678
  # Equality only. Ordering comparisons against zero (qty > 0) have bounded
679
+ # error and are usually fine; '== 0' is the trap, and this also keeps the
680
+ # checker off CHECK(qty > 0), where float_compare does not apply.
681
+ #
682
+ # Was a regex on the variable's NAME, at 2 true and 5 false. Every one
683
+ # of the five compared an integer to zero -- a search_count, a
684
+ # read_group `<field>_count`, a SELECT COUNT(*), an fields.Integer --
685
+ # where == is exact and the rule has nothing to say. Three of the five
686
+ # and both true positives were spelled `total == 0`, so the name could
687
+ # never have told them apart; the assignment can, and does.
688
+ "semantic_check": "float_equality_against_zero",
689
  "source": ODOO_CONSTRAINTS,
690
  },
691
  "odoo.prefer-sql-constraint": {
 
739
  # were the defect: the other 100 were on models the tree gives no
740
  # filtering rule, where sudo drops nothing. The AST pass asks the tree
741
  # the second half of the question.
742
+ "semantic_check": "sudo_drops_record_rules",
743
  # Record rules are a security concept, not an ORM one. The ORM reference
744
  # does not mention sudo at all.
745
  "source": ODOO_SECURITY,
 
836
  "source": PY_ERRORS,
837
  },
838
  "python.eval-and-pickle-execute-their-input": {
839
+ # 0 true, 1 false. The one finding was the word eval() written in a
840
+ # method's DOCSTRING, in a sentence arguing against using it -- not
841
+ # this rule's fault but a defect in what patterns were matched
842
+ # against, since strip_comments removed # comments and left string
843
+ # literals standing. Docstrings are now emptied before matching, so
844
+ # that finding does not recur. One sample decides nothing about the
845
+ # rule itself, which stands as written.
846
  "violation_pattern": r"\beval\(|\bexec\(|pickle\.loads?\(",
847
  "source": PY_PICKLE,
848
  },
 
854
  "source": PY_SUBPROCESS,
855
  },
856
  "python.path-join-does-not-contain-a-path": {
857
+ # Was a regex on component NAMES, at 0 true and 4 false. All four join
858
+ # a configured directory to a name this module generated -- a
859
+ # timestamped backup filename, a literal from a list declared in the
860
+ # same function, a module path from get_module_path. `filename` was in
861
+ # the alternation and says nothing about where the value came from,
862
+ # which is the only thing the rule is about.
 
863
  #
864
+ # The AST pass asks the origin instead: it fires where a component came
865
+ # from outside the file and the result is not resolved and compared
866
+ # first. That reports less, deliberately -- the regex's entire output
867
+ # on 294,000 lines was false.
868
+ "semantic_check": "path_join_takes_an_external_name",
 
 
 
 
869
  "source": PY_OSPATH,
870
  },
871
  "python.decimal-for-money-not-float": {
872
+ # 0 true, 3 false, and this one had a reason behind it rather than a
873
  # sample size. Odoo's Monetary field IS a float -- the ORM defines it
874
  # that way -- and the framework's answer to the precision problem is
875
  # float_round and float_compare against the currency's rounding, not
876
+ # Decimal. So inside an Odoo module the old pattern fired on the idiom
877
+ # and named a remedy that cannot be taken.
 
 
878
  #
879
+ # Three findings do not settle a rule either way, so it is narrowed
880
+ # rather than culled, on the line the argument actually draws: the
881
+ # remedy exists only where the value is still a string when it
882
+ # arrives. float(self.amount) is the ORM's own type and is not
883
+ # reported; float(payload['price']), where Decimal(payload['price'])
884
+ # is available, is. Money, not arithmetic, as before -- a bare float(
885
+ # caught `float(successes) / total` inside a Wilson score interval,
886
+ # which is exactly what binary floating point is for.
887
+ "semantic_check": "float_coercion_of_external_money",
888
  "source": PY_DECIMAL,
889
  },
890
  "python.naive-and-aware-datetimes-do-not-compare": {
 
975
  # construct is a real defect; nothing measured here has met one. See the
976
  # checker's docstring for how the rule came about and which half was wrong.
977
  "odoo.except-order-follows-the-hierarchy": "unproven",
978
+
979
+ # ---- graded 2026-09-21, from `recall --aim` over both ecosystems -------
980
+ #
981
+ # 795 fix commits read across OpenSPP and the two OCA trees, 237 of them
982
+ # touching a covered construct, and every finding a checker made in a file
983
+ # one of those fixes touched placed against the method the fix edited.
984
+ # The four below are the ones the placements decide; the rest stay
985
+ # `unmeasured`, which is what they are.
986
+
987
+ # 3 on-target of 20 placements, and one commit caught outright. The same
988
+ # ratio that graded sudo a defect, on a smaller sample: when this rule
989
+ # fires on a line somebody was fixing, the formatted SQL was the bug.
990
+ "odoo.sql-needs-query-parameters": "defect",
991
+
992
+ # 2 on-target of 23 placements and two commits caught, on top of the 20
993
+ # hand-classified true positives in classification-python-2026-09.json.
994
+ # An Odoo Datetime is naive UTC by contract, so a naive LOCAL datetime
995
+ # reaching one is wrong by the server's offset -- and OpenSPP's history
996
+ # carries two fixes of exactly that, which is what a defect grade means.
997
+ "python.naive-and-aware-datetimes-do-not-compare": "defect",
998
+
999
+ # 1 on-target of 14, 3 in the right method describing something else, and
1000
+ # one commit caught. The same shape as ondelete's grade and a touch better
1001
+ # aimed: the rule recognises a company-crossing relation and is about the
1002
+ # declaration rather than the line anybody fixes.
1003
+ "odoo.check-company-on-relations": "construct",
1004
+
1005
+ # 1 on-target of 25, 2 in the right method. Its twin
1006
+ # stored-compute-needs-complete-depends measured 1 of 29 and is graded the
1007
+ # same way for the same reason: critical if it happens -- a constraint that
1008
+ # never fires is the silent-bad-data case -- and measured to be about the
1009
+ # method rather than the line.
1010
+ "odoo.constrains-must-list-every-field-read": "construct",
1011
+
1012
+ # ---- the ones that fire nowhere, graded 2026-09-21 --------------------
1013
+ #
1014
+ # Same standard except-order-follows-the-hierarchy was held to, on more
1015
+ # evidence than it had: zero findings across THREE ecosystems -- OpenSPP,
1016
+ # two OCA trees and odoo core itself, 820,000 lines -- and no incident
1017
+ # behind any of them in 1,164 fix commits. `unproven` is the corpus's word
1018
+ # for a real defect nothing measured here has met, and these are it.
1019
+ #
1020
+ # The third tree is what made this safe to do. Measured on OpenSPP and OCA
1021
+ # alone, eleven rules looked like this. Four of them --
1022
+ # bare-except-catches-the-exit, mutable-default-argument,
1023
+ # eval-and-pickle-execute-their-input and subprocess-shell-true-is-injection
1024
+ # -- fire on odoo core, at 28, 15, 4 and 4. Grading them off two trees
1025
+ # would have downgraded four working rules on the strength of not looking
1026
+ # far enough, which is the mistake this grade exists to avoid making
1027
+ # quietly.
1028
+ #
1029
+ # Advisory means reported, out of the rate, gating nothing. For a rule that
1030
+ # has never fired that changes no output today; it changes what happens the
1031
+ # first time one does, and says plainly that nobody has seen it work yet.
1032
+ "odoo.check-constraint-cannot-cross-tables": "unproven",
1033
+ "odoo.constrains-ignores-dotted-names": "unproven",
1034
+ "odoo.no-monkey-patching": "unproven",
1035
+ "odoo.noupdate-on-user-editable-data": "unproven",
1036
+ "odoo.schema-change-needs-migration": "unproven",
1037
+
1038
+ # These two are silent because they were narrowed in 3.47, not because
1039
+ # nothing was there: as regexes they fired 4 and 3 times across the OCA and
1040
+ # OpenSPP trees and every one of those was classified false. Origin-based,
1041
+ # they find nothing in 820,000 lines. Both readings are in the README; the
1042
+ # grade records the second one without deleting the rules, because the
1043
+ # defects they describe are real and a third of this corpus was written
1044
+ # from constructs nobody had met yet either.
1045
+ "python.decimal-for-money-not-float": "unproven",
1046
+ "python.path-join-does-not-contain-a-path": "unproven",
1047
  }
1048
 
1049
  # --------------------------------------------------------------------- PROBES
corpus/corpus_meta.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
- "built_utc": "2026-09-16T08:00:31.662425+00:00",
3
  "rules": 27,
4
  "machine_checkable": 26,
5
- "by_pattern": 16,
6
- "by_semantic_check": 10,
7
  "checked_against": {
8
  "odoo": "18.0",
9
  "python": "3.12"
10
  },
11
- "corpus_sha256": "68dd1fc12d9d546627767f840e3acffb1d0c78fd2ff5571d259261f5312ece03"
12
  }
 
1
  {
2
+ "built_utc": "2026-09-22T08:16:07.718107+00:00",
3
  "rules": 27,
4
  "machine_checkable": 26,
5
+ "by_pattern": 12,
6
+ "by_semantic_check": 14,
7
  "checked_against": {
8
  "odoo": "18.0",
9
  "python": "3.12"
10
  },
11
+ "corpus_sha256": "8d3d07faba2e1a3f0192b95f67972f9543a8d22325da110e518adb3da0d9d997"
12
  }
corpus/rules/odoo.json CHANGED
@@ -23,9 +23,8 @@
23
  "constraints",
24
  "bypass"
25
  ],
26
- "violation_pattern": "@api\\.onchange[\\s\\S]{0,400}?(?:raise\\s+\\w*(?:Validation|User)Error|['\\\"]warning['\\\"]\\s*:)",
27
- "satisfied_pattern": "@api\\.constrains|_sql_constraints|models\\.Constraint",
28
  "stack": "odoo",
 
29
  "source": "https://www.odoo.com/documentation/18.0/developer/tutorials/server_framework_101/08_compute_onchange.html",
30
  "likelihood": "unmeasured",
31
  "advisory": false,
@@ -50,7 +49,7 @@
50
  ],
51
  "stack": "odoo",
52
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
53
- "likelihood": "unmeasured",
54
  "advisory": false,
55
  "probe": "my constraint does not run when only one of the two dates is written",
56
  "semantic_check": "constrains_lists_every_field_read"
@@ -76,8 +75,8 @@
76
  "stack": "odoo",
77
  "violation_pattern": "@api\\.constrains\\([^)]*['\\\"][a-z_]+\\.[a-z_]+['\\\"]",
78
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
79
- "likelihood": "unmeasured",
80
- "advisory": false,
81
  "probe": "constrains on partner_id.country_id never fires and raises no error"
82
  },
83
  {
@@ -99,8 +98,7 @@
99
  "intermittent"
100
  ],
101
  "stack": "odoo",
102
- "violation_pattern": "(?:price|amount|qty|quantity|total|balance)\\w*\\s*(?:==|!=)\\s*0(?:\\.0+)?\\b",
103
- "satisfied_pattern": "float_is_zero|float_compare",
104
  "source": "https://www.odoo.com/documentation/18.0/developer/tutorials/server_framework_101/10_constraints.html",
105
  "likelihood": "unmeasured",
106
  "advisory": false,
@@ -182,8 +180,8 @@
182
  "violation_pattern": "CHECK\\s*\\([^)]*(?:SELECT|[a-z_]+_id\\.[a-z_]+)",
183
  "stack": "odoo",
184
  "source": "https://www.postgresql.org/docs/current/ddl-constraints.html",
185
- "likelihood": "unmeasured",
186
- "advisory": false,
187
  "probe": "my CHECK constraint will not install because it reads the product stock"
188
  },
189
  {
@@ -207,7 +205,7 @@
207
  "stack": "odoo",
208
  "semantic_check": "check_company_missing_on_company_owned_relation",
209
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
210
- "likelihood": "unmeasured",
211
  "advisory": false,
212
  "probe": "a user in one company can select another company's journal on a document"
213
  },
@@ -298,8 +296,8 @@
298
  "data-loss"
299
  ],
300
  "stack": "odoo",
301
- "likelihood": "unmeasured",
302
- "advisory": false,
303
  "probe": "I made an existing field required and the module upgrade fails on live data",
304
  "no_check_reason": "The violation lives in the diff between two module versions plus the presence of a migrations/ directory. Nothing in a single generated snippet reveals it."
305
  },
@@ -321,7 +319,7 @@
321
  "record-rules"
322
  ],
323
  "stack": "odoo",
324
- "semantic_check": "sudo_search_drops_a_real_rule",
325
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/security.html",
326
  "likelihood": "defect",
327
  "advisory": false,
@@ -352,8 +350,8 @@
352
  ],
353
  "stack": "odoo",
354
  "violation_pattern": "from\\s+odoo\\.addons[\\s\\S]{0,300}?\\n[A-Z]\\w+\\.\\w+\\s*=\\s*\\w+",
355
- "likelihood": "unmeasured",
356
- "advisory": false,
357
  "probe": "change what a core sale.order method does without editing the sale module"
358
  },
359
  {
@@ -376,8 +374,8 @@
376
  "violation_pattern": "<odoo>\\s*<record",
377
  "satisfied_pattern": "noupdate",
378
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/data.html",
379
- "likelihood": "unmeasured",
380
- "advisory": false,
381
  "probe": "the customer's configuration resets itself every time we upgrade the module"
382
  },
383
  {
@@ -425,7 +423,7 @@
425
  ],
426
  "stack": "odoo",
427
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
428
- "likelihood": "unmeasured",
429
  "advisory": false,
430
  "probe": "a raw query breaks when a partner name contains an apostrophe",
431
  "semantic_check": "sql_built_by_string_formatting"
@@ -620,11 +618,10 @@
620
  "input"
621
  ],
622
  "stack": "odoo",
623
- "violation_pattern": "os\\.path\\.join\\([^)]*\\b(?:request|args|params|payload|form|user_input|uploaded|filename)\\b",
624
- "satisfied_pattern": "commonpath|commonprefix|relpath|is_relative_to",
625
  "source": "https://docs.python.org/3/library/os.path.html#os.path.join",
626
- "likelihood": "unmeasured",
627
- "advisory": false,
628
  "probe": "open a file the user named inside my uploads directory"
629
  },
630
  {
@@ -646,11 +643,10 @@
646
  "decimal"
647
  ],
648
  "stack": "odoo",
649
- "violation_pattern": "(?:price|amount|total|cost|balance|fee|tax|subtotal|payment|salary)\\w*\\s*=\\s*float\\(|float\\(\\s*[^)]*\\b(?:price|amount|cost|balance|fee|tax|subtotal|payment|salary)\\b",
650
- "satisfied_pattern": "Decimal",
651
  "source": "https://docs.python.org/3/library/decimal.html",
652
- "likelihood": "unmeasured",
653
- "advisory": false,
654
  "probe": "totals are off by a fraction of a penny after adding prices up"
655
  },
656
  {
@@ -671,7 +667,7 @@
671
  "stack": "odoo",
672
  "violation_pattern": "datetime\\.now\\(\\s*\\)|utcnow\\(\\s*\\)",
673
  "source": "https://docs.python.org/3/library/datetime.html",
674
- "likelihood": "unmeasured",
675
  "advisory": false,
676
  "probe": "TypeError comparing offset-naive and offset-aware datetimes"
677
  }
 
23
  "constraints",
24
  "bypass"
25
  ],
 
 
26
  "stack": "odoo",
27
+ "semantic_check": "onchange_tries_to_enforce",
28
  "source": "https://www.odoo.com/documentation/18.0/developer/tutorials/server_framework_101/08_compute_onchange.html",
29
  "likelihood": "unmeasured",
30
  "advisory": false,
 
49
  ],
50
  "stack": "odoo",
51
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
52
+ "likelihood": "construct",
53
  "advisory": false,
54
  "probe": "my constraint does not run when only one of the two dates is written",
55
  "semantic_check": "constrains_lists_every_field_read"
 
75
  "stack": "odoo",
76
  "violation_pattern": "@api\\.constrains\\([^)]*['\\\"][a-z_]+\\.[a-z_]+['\\\"]",
77
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
78
+ "likelihood": "unproven",
79
+ "advisory": true,
80
  "probe": "constrains on partner_id.country_id never fires and raises no error"
81
  },
82
  {
 
98
  "intermittent"
99
  ],
100
  "stack": "odoo",
101
+ "semantic_check": "float_equality_against_zero",
 
102
  "source": "https://www.odoo.com/documentation/18.0/developer/tutorials/server_framework_101/10_constraints.html",
103
  "likelihood": "unmeasured",
104
  "advisory": false,
 
180
  "violation_pattern": "CHECK\\s*\\([^)]*(?:SELECT|[a-z_]+_id\\.[a-z_]+)",
181
  "stack": "odoo",
182
  "source": "https://www.postgresql.org/docs/current/ddl-constraints.html",
183
+ "likelihood": "unproven",
184
+ "advisory": true,
185
  "probe": "my CHECK constraint will not install because it reads the product stock"
186
  },
187
  {
 
205
  "stack": "odoo",
206
  "semantic_check": "check_company_missing_on_company_owned_relation",
207
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
208
+ "likelihood": "construct",
209
  "advisory": false,
210
  "probe": "a user in one company can select another company's journal on a document"
211
  },
 
296
  "data-loss"
297
  ],
298
  "stack": "odoo",
299
+ "likelihood": "unproven",
300
+ "advisory": true,
301
  "probe": "I made an existing field required and the module upgrade fails on live data",
302
  "no_check_reason": "The violation lives in the diff between two module versions plus the presence of a migrations/ directory. Nothing in a single generated snippet reveals it."
303
  },
 
319
  "record-rules"
320
  ],
321
  "stack": "odoo",
322
+ "semantic_check": "sudo_drops_record_rules",
323
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/security.html",
324
  "likelihood": "defect",
325
  "advisory": false,
 
350
  ],
351
  "stack": "odoo",
352
  "violation_pattern": "from\\s+odoo\\.addons[\\s\\S]{0,300}?\\n[A-Z]\\w+\\.\\w+\\s*=\\s*\\w+",
353
+ "likelihood": "unproven",
354
+ "advisory": true,
355
  "probe": "change what a core sale.order method does without editing the sale module"
356
  },
357
  {
 
374
  "violation_pattern": "<odoo>\\s*<record",
375
  "satisfied_pattern": "noupdate",
376
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/data.html",
377
+ "likelihood": "unproven",
378
+ "advisory": true,
379
  "probe": "the customer's configuration resets itself every time we upgrade the module"
380
  },
381
  {
 
423
  ],
424
  "stack": "odoo",
425
  "source": "https://www.odoo.com/documentation/18.0/developer/reference/backend/orm.html",
426
+ "likelihood": "defect",
427
  "advisory": false,
428
  "probe": "a raw query breaks when a partner name contains an apostrophe",
429
  "semantic_check": "sql_built_by_string_formatting"
 
618
  "input"
619
  ],
620
  "stack": "odoo",
621
+ "semantic_check": "path_join_takes_an_external_name",
 
622
  "source": "https://docs.python.org/3/library/os.path.html#os.path.join",
623
+ "likelihood": "unproven",
624
+ "advisory": true,
625
  "probe": "open a file the user named inside my uploads directory"
626
  },
627
  {
 
643
  "decimal"
644
  ],
645
  "stack": "odoo",
646
+ "semantic_check": "float_coercion_of_external_money",
 
647
  "source": "https://docs.python.org/3/library/decimal.html",
648
+ "likelihood": "unproven",
649
+ "advisory": true,
650
  "probe": "totals are off by a fraction of a penny after adding prices up"
651
  },
652
  {
 
667
  "stack": "odoo",
668
  "violation_pattern": "datetime\\.now\\(\\s*\\)|utcnow\\(\\s*\\)",
669
  "source": "https://docs.python.org/3/library/datetime.html",
670
+ "likelihood": "defect",
671
  "advisory": false,
672
  "probe": "TypeError comparing offset-naive and offset-aware datetimes"
673
  }
tests/test_layers.py CHANGED
@@ -379,3 +379,94 @@ def test_version_bounds_narrow_each_other_and_alternatives_do_not():
379
  assert not layers._version_applies(">=16.0", "15.0")
380
  # A series nothing can parse still applies the rule, rather than dropping it.
381
  assert layers._version_applies(">=16.0", "saas~16.3")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  assert not layers._version_applies(">=16.0", "15.0")
380
  # A series nothing can parse still applies the rule, rather than dropping it.
381
  assert layers._version_applies(">=16.0", "saas~16.3")
382
+
383
+
384
+ # ------------------------------------------- what a marker is about
385
+
386
+ def test_a_marker_naming_another_rule_does_not_silence_this_one():
387
+ """Classifying 50 findings by hand turned up one suppression that should
388
+ not have fired: a consent-receipt timestamp inside a return statement
389
+ carrying `# nosemgrep: odoo-sudo-without-context` four lines below it. A
390
+ marker about sudo was silencing a finding about datetimes, which is the
391
+ mute button this project says it will not build, reached from the other
392
+ direction."""
393
+ import corpus
394
+ rules = [r for r in corpus.load_rules()
395
+ if r["id"] == "python.naive-and-aware-datetimes-do-not-compare"]
396
+ body = (
397
+ "def receipt(self):\n"
398
+ " return self._build(\n"
399
+ " issued=datetime.now(),\n"
400
+ " who=self.env.user.id,\n"
401
+ " ) # nosemgrep: odoo-sudo-without-context\n"
402
+ )
403
+ hits = layers.scan_source(body, rules, "odoo", errors=[])
404
+ assert len(hits) == 1
405
+ assert not hits[0].suppressed, "a marker about sudo does not speak for datetimes"
406
+
407
+
408
+ def test_a_marker_naming_this_rule_still_silences_it():
409
+ """The other side of the same line, and the measured one: 105 of OpenSPP's
410
+ 106 `.sudo().search(` findings carry a nosemgrep marker about that exact
411
+ construct. `odoo-sudo-without-context` and `odoo.sudo-bypasses-record-rules`
412
+ meet on `sudo` -- and not on `odoo`, which names a vendor rather than a
413
+ defect."""
414
+ import corpus
415
+ rules = [r for r in corpus.load_rules()
416
+ if r["id"] == "odoo.sudo-bypasses-record-rules"]
417
+ body = (
418
+ "def f(self):\n"
419
+ " return self.env['res.partner'].sudo().search(\n"
420
+ " [('active', '=', True)],\n"
421
+ " ) # nosemgrep: odoo-sudo-without-context\n"
422
+ )
423
+ hits = layers.scan_source(body, rules, "odoo", errors=[])
424
+ assert len(hits) == 1
425
+ assert hits[0].suppressed
426
+
427
+
428
+ def test_an_opaque_code_covers_the_line_as_it_always_did():
429
+ """`B608` carries no words, so it cannot be compared with a rule id at
430
+ all. Narrowing on a comparison that cannot be made would drop a decision
431
+ somebody really did record -- two of the six sql-needs-query-parameters
432
+ findings on OpenSPP sit on lines carrying nothing but `# nosec B608`. The
433
+ gap this leaves is named in _marker_is_about."""
434
+ carries, codes = layers._marker_scope(" x = 1 # nosec B608")
435
+ assert carries and codes == set()
436
+
437
+
438
+ def test_prose_after_a_marker_is_not_read_as_a_rule_name():
439
+ """People write sentences there. `# noqa: decided, TICKET-9` is a person
440
+ talking, not an analyser code, and reading `decided` as a rule id would
441
+ silently unsuppress a line somebody had settled."""
442
+ for text in (" x = 1 # noqa: decided, TICKET-9",
443
+ " x = 1 # noqa: this guard is advisory, see TICKET-41"):
444
+ carries, codes = layers._marker_scope(text)
445
+ assert carries and codes == set(), text
446
+
447
+
448
+ def test_a_statement_that_covers_the_line_beats_one_that_borrows_it():
449
+ """Every span reaches one line above its statement to take in a marker
450
+ written there, so two statements on consecutive lines overlap. A finding
451
+ on the first line of one is also inside the borrowed line of the one
452
+ below, both spans are one line wide, and the tie used to go to whichever
453
+ ast.walk reached first.
454
+
455
+ Found on OpenSPP the first time the write clause of the sudo rule fired:
456
+ spp_api_v2/wizards/show_secret_wizard.py carries its nosemgrep on line 36
457
+ about the write on 37, and the `return` on 38 borrows 37 for itself. The
458
+ marker read as absent and a decision somebody had recorded was re-raised.
459
+ """
460
+ import corpus
461
+ rule = [r for r in corpus.load_rules()
462
+ if r["id"] == "odoo.sudo-bypasses-record-rules"][0]
463
+ body = (
464
+ "def action_close(self):\n"
465
+ " if self.client_id:\n"
466
+ " # nosemgrep: odoo-sudo-without-context -- wizard ACL controls access\n"
467
+ " self.client_id.sudo().write({'client_secret': False})\n"
468
+ " return {'type': 'ir.actions.act_window_close'}\n"
469
+ )
470
+ hits = layers.scan_source(body, [rule], "odoo", errors=[])
471
+ assert len(hits) == 1 and hits[0].line == 4
472
+ assert hits[0].suppressed, "the marker one line above its own statement was lost"
tests/test_semantics.py CHANGED
@@ -941,15 +941,424 @@ def test_the_corpus_does_not_advise_a_declaration_odoo_19_ignores():
941
 
942
 
943
  def test_the_onchange_guard_knows_every_spelling_of_a_constraint():
944
- """A satisfied_pattern is a plain regex and cannot read the module's series,
945
- so it has to accept the legacy attribute: that IS a constraint on 16 through
946
- 18. Narrowing it to models.Constraint alone was tried and reverted -- the
947
- next field test reported OCA's account_loan, branch 18.0, with a real
948
  _sql_constraints block and an onchange properly backed by it.
949
 
950
  The 19 case is closed where the series is legible instead, in
951
- prefer_sql_constraint_applies."""
952
- rule = [r for r in corpus.load_rules()
953
- if r["id"] == "odoo.onchange-is-not-validation"][0]
954
- for spelling in ("@api", "_sql_constraints", "models"):
955
- assert spelling in rule["satisfied_pattern"], spelling
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
941
 
942
 
943
  def test_the_onchange_guard_knows_every_spelling_of_a_constraint():
944
+ """The guard has to accept the legacy attribute: that IS a constraint on 16
945
+ through 18. Narrowing it to models.Constraint alone was tried and reverted --
946
+ the next field test reported OCA's account_loan, branch 18.0, with a real
 
947
  _sql_constraints block and an onchange properly backed by it.
948
 
949
  The 19 case is closed where the series is legible instead, in
950
+ prefer_sql_constraint_applies.
951
+
952
+ Asserted against the checker rather than against a satisfied_pattern,
953
+ which is where the guard moved when the rule became an AST pass. The
954
+ three spellings are the same three."""
955
+ body = ('class M(models.Model):\n'
956
+ ' @api.onchange("qty")\n'
957
+ ' def _onchange_qty(self):\n'
958
+ ' if self.qty <= 0:\n'
959
+ ' raise ValidationError("no")\n')
960
+ assert semantics.run_check("onchange_tries_to_enforce", body), \
961
+ "an unbacked onchange that raises is the violation"
962
+
963
+ for spelling in (' @api.constrains("qty")\n def _check(self):\n pass\n',
964
+ ' _sql_constraints = [("q", "CHECK(qty > 0)", "no")]\n',
965
+ ' _qty = models.Constraint("CHECK(qty > 0)", "no")\n'):
966
+ backed = body + spelling
967
+ assert not semantics.run_check("onchange_tries_to_enforce", backed), spelling
968
+
969
+
970
+ # ------------------------------------------------ the value-origin trace
971
+ #
972
+ # Four rules were classified false for one reason: each read a variable's NAME
973
+ # and treated that as evidence about its value. Every case below is a finding
974
+ # somebody read by hand and recorded a verdict for, in
975
+ # classification-{oca,openspp,python}-2026-09.json.
976
+
977
+ INT_ZERO_CASES = [
978
+ ("search_count", """
979
+ def f(self):
980
+ total_population = self.env["res.partner"].search_count([])
981
+ if total_population == 0:
982
+ return 0
983
+ """),
984
+ ("read_group _count, keyed by an f-string", """
985
+ def f(self, field_name, rows):
986
+ for row in rows:
987
+ group_total = row[f"{field_name}_count"]
988
+ if group_total == 0:
989
+ continue
990
+ """),
991
+ ("SELECT COUNT(*) through a cursor", """
992
+ def f(cr, table, where):
993
+ cr.execute(f"SELECT COUNT(*) FROM {table} WHERE {where}")
994
+ total = cr.fetchone()[0]
995
+ if total == 0:
996
+ return []
997
+ """),
998
+ ("a field the same file declares as fields.Integer", """
999
+ class L(models.Model):
1000
+ qty = fields.Integer(string="Quantity")
1001
+
1002
+ def f(self):
1003
+ self.line_ids.filtered(lambda x: x.qty == 0).unlink()
1004
+ """),
1005
+ ]
1006
+
1007
+
1008
+ @pytest.mark.parametrize("label,body", INT_ZERO_CASES,
1009
+ ids=[c[0] for c in INT_ZERO_CASES])
1010
+ def test_an_integer_compared_to_zero_is_not_a_float_comparison(label, body):
1011
+ """All five false positives on this rule compared an INTEGER to zero, where
1012
+ == is exact and the rule has nothing to say. Three of them and both true
1013
+ positives were spelled `total == 0`, so the name could never have separated
1014
+ them."""
1015
+ assert semantics.run_check("float_equality_against_zero", body) == [], label
1016
+
1017
+
1018
+ def test_a_float_compared_to_zero_still_fires():
1019
+ """The guard suppresses; it must not suppress everything. `sum()` over
1020
+ amounts is the true positive that shares its spelling with three of the
1021
+ false ones."""
1022
+ body = """
1023
+ def f(sorted_amounts):
1024
+ total = sum(sorted_amounts)
1025
+ if total == 0:
1026
+ return 0.0
1027
+ """
1028
+ assert semantics.run_check("float_equality_against_zero", body)
1029
+
1030
+
1031
+ def test_a_locally_built_filename_is_not_a_traversal():
1032
+ """0 true, 4 false. All four join a configured directory to a name the
1033
+ module itself produced, and the pattern matched the word `filename`."""
1034
+ body = """
1035
+ def f(self, data_path):
1036
+ for file_config in [{"filename": "a.csv"}]:
1037
+ file_path = os.path.join(data_path, file_config["filename"])
1038
+ """
1039
+ assert semantics.run_check("path_join_takes_an_external_name", body) == []
1040
+
1041
+
1042
+ def test_a_component_from_outside_the_file_still_fires():
1043
+ body = "full = os.path.join(UPLOAD_DIR, request.args['name'])\n"
1044
+ assert semantics.run_check("path_join_takes_an_external_name", body)
1045
+
1046
+
1047
+ def test_resolving_the_path_clears_the_join_in_that_function_only():
1048
+ """The fix is to resolve and compare, and it is written beside the join. A
1049
+ realpath in a DIFFERENT function says nothing about this one."""
1050
+ fixed = """
1051
+ def f(base):
1052
+ full = os.path.realpath(os.path.join(base, request.args['name']))
1053
+ if os.path.commonpath([base, full]) != base:
1054
+ raise ValueError("escapes")
1055
+ """
1056
+ assert semantics.run_check("path_join_takes_an_external_name", fixed) == []
1057
+ elsewhere = fixed + """
1058
+
1059
+ def g(base):
1060
+ return open(os.path.join(base, request.args['other']), 'rb')
1061
+ """
1062
+ assert semantics.run_check("path_join_takes_an_external_name", elsewhere)
1063
+
1064
+
1065
+ def test_the_orm_monetary_idiom_is_not_a_decimal_violation():
1066
+ """Odoo's Monetary field IS a float -- the ORM defines it that way -- so
1067
+ inside a module the rule fired on the idiom and named a remedy that cannot
1068
+ be taken. Both shapes below were classified false."""
1069
+ for body in ("""
1070
+ class E(models.Model):
1071
+ def f(self):
1072
+ ctx = {"base_amount": float(self.amount or 0.0)}
1073
+ """, """
1074
+ def f(self):
1075
+ result = self.env["spp.cel.service"].evaluate_expression(self.e, {})
1076
+ amount = float(result)
1077
+ """):
1078
+ assert semantics.run_check("float_coercion_of_external_money", body) == [], body
1079
+
1080
+
1081
+ def test_a_money_string_from_outside_still_fires():
1082
+ """Where the value arrives as a string, Decimal(value) IS available and the
1083
+ rule's remedy can be taken. This is the rule's own wrong sample."""
1084
+ body = "price = float(payload['price'])\n"
1085
+ assert semantics.run_check("float_coercion_of_external_money", body)
1086
+
1087
+
1088
+ def test_an_onchange_does_not_answer_for_the_method_below_it():
1089
+ """All three false positives on this rule were the same failure: a short
1090
+ onchange that assigns a field, followed by an action method that raises,
1091
+ with the regex's 400-character window crossing between them.
1092
+ spp_studio/wizard/variable_remap_wizard.py is the shape, verbatim."""
1093
+ body = """
1094
+ class W(models.TransientModel):
1095
+ @api.onchange("variable_id")
1096
+ def _onchange_variable_id(self):
1097
+ if self.variable_id and self.variable_id.source_model:
1098
+ self.target_model = self.variable_id.source_model
1099
+
1100
+ def action_remap(self):
1101
+ self.ensure_one()
1102
+ if not self.new_field_id:
1103
+ raise UserError(_("Please select a field to map to."))
1104
+ """
1105
+ assert semantics.run_check("onchange_tries_to_enforce", body) == []
1106
+
1107
+
1108
+ def test_the_onchange_finding_anchors_on_the_decorator():
1109
+ """Where the rule is, and where a marker about it gets written. A
1110
+ FunctionDef's own lineno is the `def`, one line further down."""
1111
+ body = ('class M(models.Model):\n'
1112
+ ' @api.onchange("qty")\n'
1113
+ ' def _onchange_qty(self):\n'
1114
+ ' raise ValidationError("no")\n')
1115
+ found = semantics.run_check("onchange_tries_to_enforce", body)
1116
+ assert [line for line, _, _ in found] == [2]
1117
+
1118
+
1119
+ # ------------------------------------------------- sudo, all three clauses
1120
+
1121
+ @pytest.mark.parametrize("clause,body", [
1122
+ ("read", "def f(self):\n"
1123
+ " return self.env['res.partner'].sudo().search([])\n"),
1124
+ ("create", "def f(self, vals):\n"
1125
+ " return self.env['res.partner'].sudo().create(vals)\n"),
1126
+ ("write", "def f(self):\n"
1127
+ " self.env['res.partner'].sudo().write({'name': 'x'})\n"),
1128
+ ("unlink", "def f(self):\n"
1129
+ " self.env['res.partner'].sudo().unlink()\n"),
1130
+ ("handed to a call", "def f(self):\n"
1131
+ " p = self.env['res.partner'].sudo()\n"
1132
+ " return self._handle(p)\n"),
1133
+ ("returned", "def f(self):\n"
1134
+ " p = self.env['res.partner'].sudo()\n"
1135
+ " return p\n"),
1136
+ ])
1137
+ def test_sudo_covers_the_three_clauses_its_rule_states(clause, body):
1138
+ """The rule says three things -- scope it narrowly, re-apply company
1139
+ filtering, never pass a sudo recordset onward -- and only the read was
1140
+ checked. `.sudo(` is the largest cluster in the recall harvest at 70
1141
+ missed against 6 caught, and on the OpenSPP tree the unchecked write
1142
+ clause has 136 call sites against the read clause's 116."""
1143
+ assert semantics.run_check("sudo_drops_record_rules", body), clause
1144
+
1145
+
1146
+ @pytest.mark.parametrize("why,body", [
1147
+ ("the call re-applies company scoping by hand",
1148
+ "def f(self):\n"
1149
+ " return self.env['res.partner'].sudo().search("
1150
+ "[('company_id', 'in', self.env.company.ids)])\n"),
1151
+ ("the recordset never leaves the function",
1152
+ "def f(self):\n"
1153
+ " p = self.env['res.partner'].sudo()\n"
1154
+ " return p.mapped('name')\n"),
1155
+ ])
1156
+ def test_sudo_stays_quiet_where_the_rule_is_already_followed(why, body):
1157
+ assert semantics.run_check("sudo_drops_record_rules", body) == [], why
1158
+
1159
+
1160
+ # ------------------------------------------------------------- docstrings
1161
+
1162
+ def test_a_docstring_is_prose_and_is_not_matched_as_code():
1163
+ """Two of the 50 findings the Python composition added were this: `eval()`
1164
+ named in a docstring in a sentence arguing against using it, and a
1165
+ `datetime.now()` inside a doctest example."""
1166
+ src = ('def f():\n'
1167
+ ' """Never use eval() here.\n'
1168
+ '\n'
1169
+ ' >>> datetime.now()\n'
1170
+ ' """\n'
1171
+ ' return 1\n')
1172
+ view = semantics.strip_comments(src)
1173
+ assert "eval(" not in view
1174
+ assert "datetime.now()" not in view
1175
+
1176
+
1177
+ def test_a_real_string_literal_survives_and_the_geometry_does_too():
1178
+ """Only docstrings. A triple-quoted SQL query is data the checkers have a
1179
+ legitimate interest in, and a line or column that moved would misreport
1180
+ every finding below it."""
1181
+ src = 'Q = """SELECT COUNT(*) FROM t"""\n\ndef f():\n """doc"""\n return Q\n'
1182
+ view = semantics.strip_comments(src)
1183
+ assert "SELECT COUNT(*)" in view
1184
+ assert "doc" not in view
1185
+ assert len(view) == len(src)
1186
+ assert view.count("\n") == src.count("\n")
1187
+
1188
+
1189
+ def test_rebinding_self_to_sudo_elevates_the_rest_of_the_method():
1190
+ """The shape behind the one miss the README names by hand.
1191
+
1192
+ OCA server-tools cbefaa511485, "[FIX] excel_import_export: remove sudo()
1193
+ when importing record", deletes a single line -- `self = self.sudo()` --
1194
+ and says what it cost: "finding multiple records across all companies
1195
+ during import when they have same record name. Previously, importing with
1196
+ sudo could bypass multi-company record rules."
1197
+
1198
+ Nothing below that line names sudo, so every clause that looks for
1199
+ `.sudo().<method>()` is blind to it, and the recordset is never passed
1200
+ anywhere either. It replaces the thing the whole method is written
1201
+ against, which is the escape clause at its widest.
1202
+
1203
+ Run against that commit's own before and after images, the finding sits on
1204
+ line 283, is gone afterwards, and 283 is one of the two lines the fix
1205
+ changed -- an on-target catch by recall's own definition.
1206
+ """
1207
+ before = ("class X(models.AbstractModel):\n"
1208
+ " def import_xlsx(self, res_id, template):\n"
1209
+ " self = self.sudo()\n"
1210
+ " record = self.env[template.res_model].browse(res_id)\n"
1211
+ " return record\n")
1212
+ after = before.replace(" self = self.sudo()\n", "")
1213
+ found = semantics.run_check("sudo_drops_record_rules", before)
1214
+ assert [line for line, _, _ in found] == [3]
1215
+ assert not semantics.run_check("sudo_drops_record_rules", after)
1216
+
1217
+
1218
+ def test_a_rebind_keeps_the_rules_own_grade():
1219
+ """The touch and escape clauses report `unproven` because nothing measured
1220
+ has met a real one. This clause has: a fix commit that deletes the line and
1221
+ explains the multi-company leak it caused. So it carries no grade of its
1222
+ own and keeps the rule's `defect`."""
1223
+ body = ("def f(self):\n"
1224
+ " self = self.sudo()\n"
1225
+ " return self.env['res.partner'].search([])\n")
1226
+ grades = {grade for _, _, grade in
1227
+ semantics.run_check("sudo_drops_record_rules", body)}
1228
+ assert None in grades, "a rebind must not be downgraded to unproven"
1229
+
1230
+
1231
+ def test_a_sudo_recordset_bound_to_a_fresh_name_is_not_a_rebind():
1232
+ """`p = self.env['x'].sudo()` binds a new name and elevates nothing that
1233
+ was not already written against it. Only rebinding a name to its OWN sudo
1234
+ form silently elevates the code below."""
1235
+ body = ("def f(self):\n"
1236
+ " p = self.env['res.partner'].sudo()\n"
1237
+ " return p.mapped('name')\n")
1238
+ assert semantics.run_check("sudo_drops_record_rules", body) == []
1239
+
1240
+
1241
+ def test_sudo_false_is_de_escalation_and_is_never_a_finding():
1242
+ """`sudo(False)` turns record rules back ON.
1243
+
1244
+ It is how a method that must run under the caller's own rights says so.
1245
+ Odoo core does it 60 times, including mail's `_check_attachments_access`,
1246
+ whose docstring reads "This method relies on access rules/rights and
1247
+ therefore it should not be called from a sudo env." Reading it as an
1248
+ escalation reports the safeguard as the defect.
1249
+
1250
+ Neither OpenSPP nor the OCA trees contain a single `sudo(False)`, so this
1251
+ was invisible until the corpus was pointed at odoo core.
1252
+ """
1253
+ for body in ("class A(models.Model):\n"
1254
+ " def f(self, toks):\n"
1255
+ " self = self.sudo(False)\n"
1256
+ " return self.env['x'].search([])\n",
1257
+ "def f(self):\n"
1258
+ " return self.env['res.partner'].sudo(False).search([])\n",
1259
+ "def f(self, vals):\n"
1260
+ " return self.env['res.partner'].sudo(False).create(vals)\n"):
1261
+ assert semantics.run_check("sudo_drops_record_rules", body) == [], body
1262
+
1263
+
1264
+ def test_a_computed_sudo_flag_still_counts_as_escalation():
1265
+ """`sudo(flag)` can be either, and an unreadable argument must leave the
1266
+ finding standing rather than suppress it -- the same direction every other
1267
+ trigger on this rule errs in."""
1268
+ body = ("def f(self, flag):\n"
1269
+ " self = self.sudo(flag)\n"
1270
+ " return self.env['x'].search([])\n")
1271
+ assert semantics.run_check("sudo_drops_record_rules", body)
1272
+
1273
+
1274
+ def test_an_unstored_framework_field_is_not_a_stored_compute():
1275
+ """odoo/models.py adds display_name to every model as
1276
+ `fields.Char(automatic=True, compute='_compute_display_name', search=...)`
1277
+ with no store. An override that omits a dependency therefore cannot leave
1278
+ a stale row, because there is no row.
1279
+
1280
+ The checker read "no declaration in this file" as unknown storedness and
1281
+ kept the finding, which is right for an ordinary field declared in another
1282
+ module and wrong here: the framework's declaration is knowable and says
1283
+ not stored. 121 of the 1,051 findings this rule made on odoo core were
1284
+ this shape.
1285
+ """
1286
+ body = ("class M(models.Model):\n"
1287
+ " _inherit = 'res.partner'\n"
1288
+ "\n"
1289
+ " @api.depends('phone')\n"
1290
+ " def _compute_display_name(self):\n"
1291
+ " for rec in self:\n"
1292
+ " rec.display_name = rec.name + rec.phone\n")
1293
+ assert semantics.run_check("depends_covers_compute_reads", body) == []
1294
+
1295
+
1296
+ def test_a_model_that_stores_display_name_itself_is_still_checked():
1297
+ """The narrowing is about the framework's default, not about the name. A
1298
+ model that declares `display_name = fields.Char(..., store=True)` in its
1299
+ own file has opted into a stored row, and all 10 hand-verified true
1300
+ positives that compute display_name -- every one of them in OpenSPP -- do
1301
+ exactly that. Losing those is what this test exists to prevent.
1302
+ """
1303
+ body = ("class M(models.Model):\n"
1304
+ " _name = 'a.b'\n"
1305
+ "\n"
1306
+ " display_name = fields.Char(compute='_compute_display_name', store=True)\n"
1307
+ "\n"
1308
+ " @api.depends('phone')\n"
1309
+ " def _compute_display_name(self):\n"
1310
+ " for rec in self:\n"
1311
+ " rec.display_name = rec.name + rec.phone\n")
1312
+ found = semantics.run_check("depends_covers_compute_reads", body)
1313
+ assert any("'name'" in message for _, message, _ in found), found
1314
+
1315
+
1316
+ def test_a_sql_view_model_has_no_foreign_key_to_decide_about():
1317
+ """`_auto = False` means odoo builds no table for the model, so there is
1318
+ no foreign key, and `ondelete` is not a decision anybody can make about
1319
+ it. Two of the 15 sampled ondelete findings on odoo core were report
1320
+ models of exactly this kind: hr.contract.history and
1321
+ purchase.bill.line.match.
1322
+
1323
+ None of the 26 hand-verified true positives for this rule sits on an
1324
+ _auto = False model, which is what makes the exemption free.
1325
+ """
1326
+ view = ("class R(models.Model):\n"
1327
+ " _name = 'a.report'\n"
1328
+ " _auto = False\n"
1329
+ "\n"
1330
+ " employee_id = fields.Many2one('hr.employee', readonly=True)\n"
1331
+ "\n"
1332
+ " def f(self):\n"
1333
+ " return self.employee_id.name\n")
1334
+ assert semantics.run_check("ondelete_leaves_a_dangling_read", view) == []
1335
+
1336
+ table = view.replace(" _auto = False\n", "")
1337
+ assert semantics.run_check("ondelete_leaves_a_dangling_read", table), \
1338
+ "a real table with the same declaration is still the rule's subject"
1339
+
1340
+
1341
+ def test_sudo_de_escalation_is_recognised_by_keyword_too():
1342
+ """odoo's signature is `sudo(self, flag=True)`, so `sudo(flag=False)` is
1343
+ the same de-escalation as `sudo(False)`. Reading only positional
1344
+ arguments missed it, and core writes it that way twice."""
1345
+ body = "def f(self):\n return self.env['res.partner'].sudo(flag=False).search([])\n"
1346
+ assert semantics.run_check("sudo_drops_record_rules", body) == []
1347
+
1348
+
1349
+ def test_restoring_the_ambient_level_is_not_an_escalation():
1350
+ """`x.sudo(self.env.su)` sets su to whatever the environment already has,
1351
+ so it cannot raise privilege above ambient. It is how a method hands a
1352
+ record back at the caller's own level, and odoo core writes it beside the
1353
+ comment "Unsudo the invoice after creation if not already sudoed".
1354
+
1355
+ `sudo(self.env.su or field.compute_sudo)` is not the same thing: the `or`
1356
+ can turn it on, so only the bare attribute reads as ambient.
1357
+ """
1358
+ ambient = "def f(self, invoice):\n invoice = invoice.sudo(self.env.su)\n return invoice\n"
1359
+ assert semantics.run_check("sudo_drops_record_rules", ambient) == []
1360
+
1361
+ may_raise = ("def f(self, rec):\n"
1362
+ " rec = rec.sudo(self.env.su or self.compute_sudo)\n"
1363
+ " return rec\n")
1364
+ assert semantics.run_check("sudo_drops_record_rules", may_raise)