constrains-ignores-dotted-names said "api.constrains silently ignores
dotted field names". Odoo 18 is not silent about it: _constraint_methods
logs "@constrains parameter %r is not a field name" the first time the
model collects its constraints (odoo/models.py, 18.0 at 3c3e3b3), and
the check still never fires for that name. The title now reads "ignores
dotted field names without an error", which is exact. The why already
said a dotted name is not an error, and is unchanged.
The title is embedded text, so nexa build re-embedded the index and
regenerated the Modelfile, and ai-odoo was re-registered. Retrieval was
measured before and after: 28 of 28 at top 6 and 26 at top 3 both times,
no rule changed rank, and the rule stays first after the pinned one for
its own probe, at 0.700 where it was 0.694.
794 passed, 2 skipped; every marked figure agrees.
constrains-ignores-dotted-names was a regex for a quoted name of two
[a-z_] segments. It was silent on partner_id.country_id.code, where a
dot came where it wanted the closing quote, and on any dotted name
through an l10n_ field, which has a digit. It is an AST pass now,
constrains_declares_a_dotted_name, reading the arguments with
_decorator_args as the other constrains checker does: one finding per
method, on the decorator line, saying whether a plain name is left to
trigger the check. *FIELDS and the callable form go to the gap channel.
Both constrains checkers meet that method, so _note_unreadable now
records it once, rather than check printing it twice and fieldtest
counting it as two files it could not fully read.
OCA 99, OpenSPP 157 and core 2338 unchanged, row for row, and core's
console summary is identical to a run of the old code (the same 28
not-fully-read notes). 0 dotted names in 815 @api.constrains
across the three trees, so the rule stays NEVER FIRED and unproven; it
i…
odoo.sql-constraints-are-ignored-from-19, from OpenSPP 208d9758 (#394):
a _sql_constraints block on a 19.0 module, which Odoo 19 logs once and
never creates, so duplicates were saved silently. An AST pass over the
file plus the manifest's series, legacy_sql_constraints_on_19. Reported
on 19+; silent on 16 to 18, where it is the only spelling; silent when a
walked tree pins no series (odoo core, 174 files); advisory per finding
when nothing was walked at all.
Fires nowhere at HEAD on the three trees, as expected: OpenSPP had
migrated, OCA and core are 18.0. OpenSPP's history holds the evidence:
it catches all four fixed instances (three in ca44b5e3, one in 208d9758)
on the files before their fix and none after, and nexa check reports the
#394 file at the line the fix removed. Graded defect: on 19 the attribute
does nothing, so every finding is a constraint that does not exist.
Also corrects "models.Constraint arrived in Odoo 17": 18.0 core has none
and the 18.0 ORM reference has no Co…
naive-and-aware-datetimes-do-not-compare was a regex on every
datetime.now() and utcnow(). It is an AST pass now,
naive_time_leaves_or_dates_a_day, reporting the two shapes that survived
reading (ARCHITECTURE 3.56): a naive timestamp leaving with no offset (its
isoformat() anywhere, or the value in a response class or returned dict
at an HTTP edge), and the server's UTC date used as a calendar day (a
.date() into a date key, domain, Date default or date attribute, or a
naive now() into a key named `date`). Sources unchanged: date.today() and
fields.Date.today() were measured, not added.
OCA 7 -> 1, OpenSPP 25 -> 5, core 167 -> 2. Every OpenSPP/OCA finding that
went had been read false; the three true positives stay, and both recall
catches still fire on the pre-fix files. Core's removals were triaged and
the 84 near HTTP or a date/serialisation shape read. That reading found two
true positives an HTTP-only draft had dropped: l10n_es_edi_facturae and
l10n_es_edi_tbai write a naive isof…
datetime: Odoo runs in UTC, and the rule that carried the Python half did not know
Sampling naive-and-aware-datetimes on core found its premise false inside
Odoo. import odoo pins the process to TZ=UTC (odoo/_monkeypatches, as
OpenERP did since 2013), and fields.Datetime.now() is datetime.now()
itself. There is no server offset for datetime.now() to be wrong by. Core's
sample is 0 of 15.
Re-read under a criterion that does not depend on the server clock, the
OpenSPP and OCA verdicts are 3 true of 33, from 21. Eighteen reversed,
each keeping was: true. What stands: naive UTC timestamps leaving through
an API with no offset (the shape recall's two catches fixed with
datetime.now(UTC)), and the server's UTC date used as a company's calendar
day. The composed-in Python rules go from 27 of 51 to 9 of 51.
The rule's advice was wrong for Odoo too: an aware datetime written to a
Datetime field raises ValueError. It now says aware belongs at the edges
and names fields.Date.context_today for a user's day. Graded preference,
so it gates nothing; narrowing the checker to the t…
xml: read the data files, and ask noupdate the question per record
The noupdate cluster was silent for a structural reason. The rule checks
XML, and fieldtest and recall read .py only, so it had never fired at real
code. nexa check was worse: it parsed XML as Python, failed on line 1, and
reported a valid file "not fully checked".
XML is a file kind now. Rules declare applies_to, scan_source runs only the
rules for the kind it reads, and bad XML is a gap with its line. fieldtest
reads XML and counts it apart, so no published Python rate moves; recall
--xml is its own measurement with its own denominator. Demo files are
skipped (Odoo loads them noupdate), and test_*.xml data a manifest lists is
no longer skipped as a test by its name.
The rule was a regex that fired once on <odoo> for any record, views
included, and was cleared by one noupdate anywhere. It is now an expat pass
per <record>, over the models odoo core itself keeps noupdate: parameters
26/28, sequences 16/17, decimal precision 7/8, stages 30/34. Crons came in
on OpenSPP #473 and were na…
recursion: ask whether the ORM already refuses the cycle, and whether it can form one
Reading spp_area behind a true verdict: spp.area sets _parent_store = True,
and odoo's _parent_store_update raises "Recursion Detected." on any write
that would close a loop, as it has since 2019. Six of the rule's seven true
verdicts were models like that, and are reversed to false with was: true
kept on each row. spp.dms.directory stands: a parent_path Char and no
_parent_store, so nothing refuses the loop its recursive computes follow.
The rule was a regex for `parent_id = fields.Many2one`. It is now an AST
pass, parent_can_form_a_cycle, that fires only when parent_id points at its
own model (read through a string, _name, or a shared module constant) and
_parent_store does not guard it. That also closes the wrong-construct false
positive ARCHITECTURE 6 left waiting for this migration.
Core caught three defects in the new checker before it shipped: chained
`_name = _description = 'x'` read as nameless, SQL views, and a field
re-declared with no comodel. OCA 2 -> 0, OpenSPP 6 -> 1, …
precision: read the named false positives, and the misses that named themselves
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 n…
Four stacks become one. The TypeScript and JavaScript rules are removed, the
eight plain-Python rules are composed into the Odoo stack, and a file in any
other language is refused by name rather than reviewed against rules that
cannot match it.
WHY REMOVE RATHER THAN LABEL. Firing the other twenty-five rules at real code
decided it: JavaScript classified out at 21 true against 22 false, TypeScript
was measured at 3.8/kloc and never classified. A rule that costs nothing at
query time is not free -- it is a claim the README carries and a file `check`
accepts and then says nothing useful about. An empty finding list reads as a
clean bill of health, so `subject.Unreviewable` refuses the file instead.
WHY COMPOSE RATHER THAN DELETE. Selection was strict equality on `stack`, so an
Odoo module got the 19 ORM rules and none of the 8 Python ones -- and Odoo
modules are Python. `fieldtest --stack python` over OpenSPP found 32 hits
`--stack odoo` never reported. "ruff covers it" does not hold a…
sudo: the rule the classification killed, and the marker it could not read
The other 163 OpenSPP findings are now classified, which is all 268. One rule
was 106 of them, every one critical, and reading them put it at 6 true and 97
false. That is not a tuning problem. Its own sentence is that sudo "drops
ir.rule filtering" and the regex could see the sudo and never the filtering:
73 fired on models the tree gives no ir.rule at all, and 10 more on
spp.vocabulary.code, whose single rule is [(1,'=',1)] and filters nothing.
So context.tree_facts now reads every <record model="ir.rule"> in the walk,
and the rule is an AST pass that fires only where a filtering rule exists. The
trigger only ever suppresses: an unwalked tree, a partial walk, or a model the
walk never saw declared each read UNKNOWN and the finding stands, so `nexa
check` on one file behaves as it did before.
That last clause is there because the first version did not have it and cost
three true positives. A walk of an addons tree sees the addons' rules and not
Odoo's own, so product.template -- whic…
review: a pass over the tool itself, and the silence it was keeping
The corpus has been measured from every angle; the code running it had not
been read once. The worst thing here was not a wrong answer but an absent
one.
A SYNTAX ERROR ANYWHERE ERASED EVERY AST FINDING. Each semantic checker
parses for itself and returns [] when that fails, so nine of them agreed
there was nothing to say and `check` printed "no checker findings" and
exited 0. A file with a real stale-compute bug reported it; the same file
with a trailing `def oops(:` reported nothing. scan_source now parses once
and returns a gap with the line number in it.
@api.depends DID NOT COVER ITS OWN PREFIX. Declaring 'line_ids.price'
registers the whole chain, so a body reading rec.line_ids needs nothing
more. Exact string matching called that stale: 42 of the rule's 104 hits
on OpenSPP, 40% of its output. Its own `correct` sample hid it by
declaring the prefix redundantly, which is why validate never saw it.
THE OTHER 25 RULES HAD NEVER BEEN FIRED AT REAL CODE. Every committed
artifact wa…
severity: two axes, and the gate falls out of the second one
`severity` was doing two jobs. It said how bad a thing is if it happens and it
was also read as how likely a finding is the bug, and those are independent.
`prefer-sql-constraint` is the case that made the conflation impossible to keep.
A duplicate reaching production is genuinely severe. Across 160 real fixes the
rule fired 49 times and not once on a line anybody changed. Written as one
field, either the impact is understated or the noise is overstated, and the rule
spends its life arguing with its own findings.
So there are two fields now.
severity IMPACT ONLY. Unchanged values, narrowed meaning.
likelihood how likely a finding from this rule is the bug.
LIKELIHOOD IS GRADED BY MEASUREMENT
Not by argument. `recall --aim` already places every finding a checker made in a
file a fix touched against the method the fix actually edited, and the grades are
read straight off that table:
rule same on other
sudo-bypasses-record-r…
rules: except-order ships advisory, and advisory grows a second ground
Asked to ship the exception-shadowing checker advisory. It does not fit the
label as written -- "states a design preference rather than a defect" -- because
`except Exception` above `except KeyError` is dead code, not a taste. Shipping
it under that wording would make the field mean two things and stop being worth
reading.
So the field names two grounds instead, each with its own bar. One mechanism
serving two reasons is fine; one word meaning two things is not.
preference a better way to write correct code
bar: recall --aim, never fires on a line somebody fixed
unproven a real defect nothing measured here has met yet
bar: zero findings in fieldtest, no incident in recall
prefer-sql-constraint is the first. odoo.except-order-follows-the-hierarchy is
the second, and the rule's own `why` says which it is, as the schema now
requires.
HOW THE RULE GOT HERE, INCLUDING THE WRONG PART
A tally over the 62 fixes the corpus was silent on said nine…
rules: advisory, and prefer-sql-constraint becomes the first one
3.37 ended with two honest options for a rule that has never once fired on a
line anybody fixed: label it advisory or drop it. This is the first.
`advisory: true` is a new optional schema field meaning the rule states a design
preference rather than a defect. Following it is better; not following it is not
a bug.
The label has teeth. An advisory finding is reported everywhere and gates
nothing:
check its own heading, and out of the exit code
fieldtest counted apart from the per-kloc rate
the semantic layer the finding is recorded, the verdict does not follow it
Reported and NOT dropped, which is the deliberate half. A Python uniqueness
check really does race and the reader still wants to see it; what changes is
that nothing fails because of one. The precedent is `decided` -- printed,
excluded from the rate, never dropped -- and the reasoning is its mirror. A
report that re-raises settled questions gets skimmed. A gate that fails a build
over a pref…
provenance: the version claims against source, and the gate gets tests
VERSION CLAIMS, FROM THE SOURCE THIS TIME
`check_sources.py` reads the documentation, and documentation has one blind
spot the corpus had already fallen into: a function Odoo shipped and never
described reads as missing. That is why float-comparison-needs-float-utils
carried a hand-written basis while every other rule rested on a page.
`--source` reads odoo/odoo itself and asks whether the API is DEFINED in that
series' module -- `def float_compare(` in odoo/tools/float_utils.py, not the
word float_compare anywhere on a page. Fetched per file rather than cloned:
three series of two branches is a gigabyte to answer a question about
eighteen symbols.
54 of 54 rule-series defined, across 16.0, 17.0 and 18.0
That closes it. No Odoo rule's range now rests on an assertion, float_compare
included -- its signature is byte-identical in all three series, which the
basis field now states as a measurement instead of a belief.
Two things worth recording that only the source could settle:
…
crosswalk: an outside opinion, and three rules with something behind them
Two gaps in `nexa check`, both of them about the corpus rather than the code:
it can only find what it has a rule for, and the worklist that was meant to
say where the missing rules are had been pointing at the wrong place.
AN OUTSIDE OPINION
crosscheck, adversarial, fieldtest and recall all ask whether what we wrote
behaves. None of them can ask whether it is the right set of things to have
written, because a rule nobody thought of is invisible to every check that
reads the rules.
OpenSPP runs semgrep against 44 hand-written Odoo rules of its own. `nexa
crosswalk` compares the two corpora without running semgrep -- not installed
here, and an offline tool will not grow a package index for one question.
Each of their patterns becomes the smallest snippet it would match and OUR
checkers run over that, so every pairing rests on a checker firing rather
than on two rule descriptions sounding alike.
their patterns turned into a probe 146 of 146
both corpora fire on the sam…
recall: cluster the misses, and check every version claim
Four of the five things left after the check rebuild. The fifth needs a model
and this machine's Ollama is down, which is said plainly rather than worked
around.
THE MISSED LIST BECOMES A WORKLIST
3.25 said the missed list was the more useful half of recall: every entry is a
bug somebody found, fixed and pushed, which makes it a candidate rule with an
incident behind it. A flat list of 142 subjects is not a worklist, and that is
what it was.
`nexa recall --harvest` clusters them by the construct each diff touched, ranks
the constructs by how many misses they carry, and marks where the corpus already
catches something. scope_hits returns the list it matched instead of a boolean,
which is what makes the clustering possible at all.
Measured against OpenSPP 2 (Odoo 19), 1,391 commits -- a second ecosystem, on a
newer series than the OCA repositories:
368 fix commits examined
147 touching a construct the corpus covers
6 caught 3% recall …
check: read the file, instead of only asking a model about it
3.26 measured `nexa check` and found it the wrong shape in two ways, neither of
them about the model's quality.
THE CHECKERS NEVER SAW THE FILE
Every layer ran over the model's ANSWER. So the corpus could find six things in
fsm_visit.py -- an incomplete @api.depends on three stored computes, an
@api.constrains reading two fields it does not declare, a uniqueness test racing
in Python -- and the command reported none of them, because the 7B had not
happened to mention them. The same corpus through `nexa fieldtest` found all six
with no model installed.
layers.scan_source is now the one answer to "what do the checkers say about this
source file", and check and fieldtest both route through it. check runs it before
generation and prints the findings with their line numbers. The model is then
shown them and asked to explain and repair them, and to look for what no pattern
can decide -- a better question than "review this file", which invited a rewrite
and reliably got one.
THE EXCERPT W…
python: a fourth stack, so a plain .py file gets a real answer
Adding the stack is most of this. Registering its model is what exposed the
rest.
1. The stack.
nexa check mapped every .py to Odoo, then for a while refused a .py with no
odoo import. Refusing was honest -- fifteen ORM rules produce fifteen findings
about a framework the file does not use -- and it was not an answer.
Eight rules, the same subject as the rest of the corpus: validation, and the
places a check that looks like it holds does not.
assert-is-not-validation -O removes it, so it is absent in
production and present in development
mutable-default-argument evaluated once, shared by every caller
bare-except-catches-the-exit swallows KeyboardInterrupt and SystemExit
eval-and-pickle-execute-their-input
subprocess-shell-true-is-injection
path-join-does-not-contain-a-path an absolute second argument discards the
base entirely
decimal-for-money-not-float
naive-and-aw…
provenance: audit the 29 cited sources for topic fit
"Traceable to upstream documentation: 29 (91%)" was counted and never checked.
Reading the list, eight citations pointed somewhere that does not contain the
claim:
zod.dev/ the Zod HOMEPAGE, cited by four rules. A landing
page supports nothing.
.../JavaScript/Reference the MDN section index, cited by one.
the Odoo ORM reference cited by noupdate-on-user-editable-data, which is
a data-file attribute the ORM page never mentions;
by the three TypeScript rules about JSON-RPC return
shapes, which is the External API page; by
sudo-bypasses-record-rules, which is Security, and
the ORM reference does not mention sudo at all; and
by no-monkey-patching, which no Odoo page states.
Each replacement was checked to resolve and to cover its subject: data.html
mentions n…
checkers: two more move to AST, and the OCA findings are classified
Item 3 and item 5 from the open list, and they turned out to be the same
piece of work: classifying the hits is what showed where the checkers were
wrong.
check-company-on-relations was a regex over one declaration, and whether the
model has a company at all is the condition that decides the rule. Of its 37
hits on OCA:
20 the class declares company_id locally true positives
11 TransientModel wizards a different, smaller problem
6 _inherit = 'res.company' wrong -- odoo/fields.py has
a branch for exactly this
It now requires the model to be company-scoped, read from the code rather
than assumed: the class declares a company_id of its own. A class that only
inherits one cannot be resolved without an Odoo registry and is skipped
rather than guessed -- silence over a false critical. 37 -> 19.
stored-compute-needs-complete-depends never checked the word in its own name.
Classifi…
ondelete: fire where the orphaning actually bites, and record the eval
Three things, entangled in ARCHITECTURE.md and README.md and committed
together rather than pretending otherwise.
1. ondelete-is-a-decision moves from a regex to an AST pass.
The regex fired on every optional Many2one with no ondelete: 142 hits over
34,550 lines of reviewed OCA code, 4.1 per 1000 lines. All true positives by
the rule's letter, and collectively loud enough to bury the other nine Odoo
rules under one that is a policy rather than a defect.
The harm the rule names needs a READ. 'set null' nulls the reference, and a
later read THROUGH it yields an empty recordset that Odoo renders as False
rather than raising -- "dangling references that later reads treat as empty"
is the rule's own wording. A field declared and never dereferenced cannot
exhibit that.
regex over the declaration 198 hits 5.66/kloc
...exempting required=True 142 4.11
AST: no ondelete AND read through 33 0.96
corpus total 293…
fieldtest: point the checkers at real code, and narrow three of them
crosscheck proves a pattern does not fire on the corpus's own correct
samples -- 32 snippets written by whoever wrote the patterns, so passing it
proves the author is self-consistent. adversarial proves a checker survives a
rewrite of its own sample. Neither answers the question that decides whether
anyone would run this on their own repository.
`nexa fieldtest <folder>` fires every checker at a tree of somebody else's
source and reports the hit rate per thousand lines, per rule, with examples.
No model, no network, seconds.
Run against OCA/server-tools and OCA/account-financial-tools -- 606 files,
34,550 lines of reviewed, merged, shipped Odoo:
before 397 hits 11.3 per 1000 lines
after 293 hits 8.5 per 1000 lines
Three checkers were narrowed on the evidence, one rule was left alone with
its rate recorded, and one of the hits turned out to be a real bug in OCA.
depends_covers_compute_reads 121 -> 95. It demanded a depends entry for
paths ending in .id, w…
corpus: correct two rules against Odoo 18's source, and settle the cross-hit
The 32 rules had been taken on faith for the whole test pass. Read against
odoo/odoo at 18.0, which is the series corpus_meta claims they were checked
against. Two are wrong there:
recursion-check-on-hierarchy recommended _check_recursion(). In 18.0 that
method warns -- "Since 18.0, one must use not _has_cycle() instead"
-- and delegates. The rule now says _has_cycle(), with the sense
inverted, and names the older spelling for 17.0 and earlier. Its
satisfied_pattern accepts both: code carrying _check_recursion is out
of date, not broken, and this rule is not the place to say so.
ondelete-is-a-decision said "the ORM default is set null". True only for
an optional Many2one. fields.py defaults a required one to restrict
-- and to cascade from a TransientModel -- so the default differs
between two fields declared a line apart, and adding required=True
later silently changes deletion behaviour. Said properly now…
Validation was two checks: do the cited rule ids exist, and does the code match
a violation pattern. Both survive as the knowledge and domain layers, but
neither ever saw an answer whose Python does not compile, whose @api.constrains
omits a field it reads, or whose imports name a module that is not installed.
Those were passing.
Every answer now goes through nine layers -- syntactic, structural, semantic,
knowledge, domain, context, empirical, adversarial, provenance -- and each
reports a verdict, what it checked, and what it could not check and why. A layer
that cannot run reports UNKNOWN, and the decision carries that forward as
INSUFFICIENT EVIDENCE rather than letting it read as a pass. Confidence comes
back as four separate words and is never averaged into one number.
Two real defects this found in the existing product:
* All 12 satisfied_patterns in the corpus could be cleared by a COMMENT quoting
the fix instead of applying it, four of them on critical rules. Writing an
…