3.72 declined the lambda's binding and 3.80 the name assigned records,
each because its additions read about twice as many false findings as
true, and each named facts from outside the file as what would
separate them. 3.83 built those facts. Neither binding's code was
kept, so each was rebuilt from its section and first run where it was
measured: the name binding on 0539253 reproduces 3.80 exactly (OCA
102 -> 107, OpenSPP 161 -> 169, core 2,366 -> 2,480, the same four
gone), the lambda binding on fed0255 reproduces 3.72 (OCA +8, OpenSPP
+1, core +127 depends and +27 constrains).
On today's tree: name binding OCA 101 -> 106, OpenSPP 158 -> 166, core
+86 depends (was 113) and the same 5 constrains; lambda binding OCA 101
-> 108, OpenSPP 158 -> 159, core +89 depends (was 127) and +25
constrains. The facts only remove findings, and every addition today
was read then but two, so the old readings bound the new ones without
reading again: the name binding at most 45 true of 98 depends findiβ¦
check: dependencies are read when the depends check asks, and where namedβ
3.84 left two things. check read the modules beside the one it was
given for every file, though only a file with a depends finding left
can use them, and looked for them beside the module only, so an OCA
module, whose dependencies are core's in another directory, got none.
module_facts now leaves a loader in the facts (with_dependencies), and
the depends check calls it only for a read the module's own
declarations leave uncovered, then asks again about those reads alone.
More declarations only cover more, so the findings are the eager
read's exactly. The loader runs once per module and adds only the
dependency files' declarations to what the walk already read
(orm_inputs). fieldtest's facts carry no loader.
nexa check --addons-path <dir>[,<dir>...] names where else they are
installed, as Odoo's own option does: beside the module first, then
each named directory in turn. A named directory that is not there is
refused, exit 2. With core's two addons directories named, OCA's
closures reβ¦
figures: the cross-hit row is read from the probes' committed rowsβ
The coverage table's "0 cross-hits across the corpus" was its last row
without a marker: 3.76 committed the probes' rows and left this one to
crosscheck, which writes nothing down. Giving crosscheck an artifact of
its own would have committed the same rows twice. The contradiction
probe fires every checkable rule at every other rule's correct sample,
over the same rules and through the same layers.fires, and keeps one
row per cross-hit before it classifies it.
So what a cross-hit is has one definition now, layers.cross_hits, which
crosscheck lists and the probe classifies. figures.py counts the probe's
contradiction rows as adversarial.crosshits and the README row is
marked. CI's corpus job already checks those rows against a fresh run,
so a pattern that starts to fire on another rule's correct sample fails
CI until they are rewritten. crosscheck prints what it printed, byte
for byte, and the probes reproduce their artifact. A test plants a
cross-hit and finds it in both. ARCHITECTUREβ¦
check: the modules a module depends on are read beside itβ
nexa check walked only the module it was given, so the merged
@api.depends and the transitive triggers 3.83 brought to the depends
check could not reach a definition or a field in another module, and
spp_cel_event's two findings, which fieldtest no longer reports, still
showed there. module_facts now also reads every module the manifest
depends on, transitively, that sits beside its module, as an addons
directory keeps them (_dependency_sources). They are read for their ORM
declarations and their manifests' depends alone (tree_facts takes them
as dependencies), tests left out as fieldtest's trigger walk leaves
them out. Every other fact stays the module's own and the walk stays
incomplete, so A never applies in check. Past 1,000 Python files,
about two seconds of parsing, nothing beyond the module is read: one
module in the three trees, core's test_event_full. orm_declarations
parses without the compile step it never read: the same records in
about three quarters of the time.
Measuredβ¦
depends: storedness, merged depends and transitive triggers read from the treeβ
stored-compute-needs-complete-depends judged each file alone, and three
of the reasons its findings were false are facts about declarations
the tree holds (proposed in 3.72, counted again in 3.71, 3.78 and
3.80). context.tree_facts now reads every Python file's ORM
declarations (semantics.orm_declarations): per class the model, as
Odoo names it, and what it inherits, each field's compute, related
path, store, inverse and comodel, and each method's literal
@api.depends, each with its module, plus every manifest's depends
(context.module_closure). fieldtest hands each file the facts with its
module (context.for_file). A file over 200,000 characters is read whole
for this; the older facts keep their limit.
A, a compute nobody stores: only from a complete walk that names the
model and everything it inherits; nexa check walks one module and
never applies it.
B, merged depends: Odoo merges @api.depends across the MRO, taken from
the reader's own module and its manifest closure only. C, tranβ¦
constrains: a field Odoo cannot trigger on is asked for by its inputsβ
constrains-must-list-every-field-read asked a constraint to name every
field its body reads, and Odoo will not let it name some of them. A
field neither stored nor inversible is never among the fields a write
hands to _validate_fields, so a constraint naming it never runs for it:
_constraint_methods logs "@constrains parameter ... is not writeable"
and does nothing more (odoo/models.py, 18.0). 3.71 met one, 3.72
counted eleven, and the OpenSPP reading carried one as false.
The checker reads the constraint's own class (_class_fields) and
applies Odoo's own test, three-valued (_writeable): a compute is
unstored unless store=True and inversible only with inverse=, and a
related field is unstored and readonly unless it says otherwise. What
can trigger the check is a write of what the field is computed from,
its compute's @api.depends roots or the first field of its related
path, followed through inputs that cannot be named either
(_unwriteable_inputs). A constraint naming every input is lβ¦
layers: an advisory finding gates nothing in the answer layers eitherβ
3.38 says an advisory finding gates nothing, and 3.77 found the answer
layers letting one decide. Reading the code for the change found four
places, not two: the domain layer's verdict; decide(), which combines
the layers by their findings rather than their verdicts, so a
preference printed WARN and an answer whose only critical finding was
advisory printed REJECT (nexa ask exiting 1); worth_escalating; and the
grade a trigger gives one finding, which check reads and the answer
layers did not (sudo's touch and escape clauses, a legacy
_sql_constraints in a bare snippet). Of the three answers 3.77 left
open, the first is taken: advisory decides neither a verdict nor
escalation.
Finding.advisory is set by one function, is_advisory(rule, grade),
which scan_source now calls too, so a file and an answer cannot
disagree. render labels an advisory finding "advisory, gates nothing".
test_pipeline escalates on the onchange question, both answers recorded
verbatim from a nexa run; NEGATIVE_QTY β¦
18 days ago
depends: a name assigned records, measured and not takenβ
pending = ...sorted("sequence")[:1], then pending.tier_id, reads
nothing past the name, because _aliases binds loop variables only
(ARCHITECTURE 3.71). The binding was built, a name bound when every
value the function assigns it resolves to one path, and measured
against the committed artifacts: OCA 102 -> 107, OpenSPP 161 -> 169,
core 2,366 -> 2,480.
Every addition was read. OCA 5 of 5 true, all smart defaults. OpenSPP
4 of 8: the approval tier 3.71 named, the vocabulary mapping's stored
name (two), and one default issuer per company; the false ones are
three attributes of a geometry and a read feeding a non-stored field.
Core's depends 37 of 113, 28 of them smart defaults, the ratio 3.72
declined for the lambda binding. The false ones are values kept on
purpose (27), fields stored nowhere (17), reads a declared compute
already covers (11), fields refused or fixed at creation (11), merged
decorators (5) and five of a kind. Core's constrains 2 of 5.
Not taken: a test pins it, and _alβ¦
19 days ago
ondelete: a read through mapped(), measured and not takenβ
ondelete-is-a-decision counts only <x>.<field>.<attribute> as a read
through a Many2one. A literal mapped("<field>.<attribute>") reads
through it too, and was built and measured against the committed
artifacts: OCA 102 and OpenSPP 161 unchanged, core 2,366 -> 2,367.
The change saw thirteen such reads: none on OCA, four on OpenSPP, nine
on core. Twelve are on fields an attribute read already reports. The
one it adds is false: stock.location's parent, location_id, read as
children_quants.mapped('location_id.display_name'), which is a quant's
location. The check places a read by the field's name and not by the
model it is read on, and five of the thirteen are another model's field
of that name. None of this rule's 41 hand readings was false for that
reason, so the class is one the attribute reads have not met, and the
change adds nothing true. Not taken: a test pins it, and the helper's
docstring says why. ARCHITECTURE 3.79.
834 passed; every marked figure agrees; 289 of 289 true positiβ¦
depends: a read straight off a call that keeps the records is a readβ
rec.sudo().partner_id.name read nothing past the call: _attr_path
stopped at a Call. _resolve_iterable already lists the calls that hand
back records of the same path (sudo, with_context, filtered, sorted,
exists), since a loop's iterable goes through it, so a chain whose base
is a call now resolves through the same list, and a loop and a read
cannot disagree. mapped(), browse() and a method on the record stay
unread.
Measured on the three trees, each first re-scanned with the committed
checkers and matching its artifact hit for hit: OCA 101 -> 102,
OpenSPP 161 unchanged, core 2,357 -> 2,366, all the depends check,
nothing removed. All ten were read. OCA's is true and recorded in
classification-oca (account_check_deposit's in-hand check account).
Core's nine are 4 true and 5 false, the false ones classes the check
already has for attribute reads: storedness in another module (three,
pos_sale over sale's non-stored amounts), coverage through a declared
compute, and a value kept on purpβ¦
eval: the pipeline rows re-measured on the nine Odoo questionsβ
The README's stability and escalation rows rested on eval-2026-09.json
of 2026-09-14, and six of its twelve questions were TypeScript and
JavaScript, which left the corpus in 8d155aa: 10 of its 21 escalations
and 5 of its 11 rescues. Re-run over the nine questions eval.py asks
now, five runs each: PASS 38 of 45, 8 of 9 questions with one verdict
on every run, 18 escalations and 11 rescued. figures.py reads
pipeline.* from the rows and fails on an answer for a stack the corpus
no longer has, and on rows that carry no escalation fields. The README
rows are marked, and name nexa run, which is what --pipeline measures.
The run found a disagreement it does not settle. prefer-sql-constraint
is advisory, which 3.38 says gates nothing, yet the domain layer lets
it decide and worth_escalating counts it. 10 of the 18 escalations
were on an advisory finding alone, and the negative-quantity question
ends UNVERIFIED five times because both models write the same
constraint. The pipeline's own testsβ¦
adversarial: the probes' rows are committed, and CI checks themβ
The README said the corpus currently meets all three probes, with 99/99
rewrites caught, 0 of 13 satisfied patterns bypassable and 0
contradictions, and that the numbers were regenerated on every build so
a checker could not quietly weaken. Nothing wrote them down: they had
not moved since TypeScript and JavaScript left the corpus, and today
they are 82/82 and 0 of 1. Nor could the build have caught a weakening,
since adversarial.py fails only when a critical rule loses a rewrite.
adversarial.py all --json writes every row the probes produce, with the
corpus hash and no timestamp, so two runs write the same bytes. --check
compares a fresh run with the committed file and names what moved. The
printed report is unchanged byte for byte, printed from the same rows.
nexa build writes artifacts/adversarial-2026-09.json, CI's corpus job
runs --check, and figures.py reads adversarial.* from it, so the README
rows and its paragraph are marked. The README, fieldtest's docstring and
nexa help fiβ¦
artifacts: the measurements move out of the repository's rootβ
Twenty-two JSON files sat beside README.md: the classifications, the
fieldtest and recall runs, the surfacing verdicts and the two eval runs.
They live in artifacts/ now, moved with git mv so each keeps its
history.
figures.py reads them from there. Its classification loader took each
file's basename and loaded that from the root, which a subdirectory
breaks, so it keeps the path the glob found. The README and ARCHITECTURE
mentions carry the folder, so each still names a file that exists. The
one left bare is classification-js-2026-09.json, deleted with the
JavaScript corpus, where it stays as history. The README's surfaced.py
command gains the path --check requires, and the Layout lists the
folder.
812 passed, 2 skipped; every marked figure agrees.
check: the eval rows re-measured on the subjects left, and checkedβ
README's nexa check rows rested on eval-check-2026-09.json of
2026-09-14, and two of its five subjects went with the TypeScript and
JavaScript corpora in 8d155aa. Re-measured over the three left, five
runs each: 10 findings in 3 of 3 files, the same on every run, and 3
advisory reported apart; PASS, WARN and REJECT 5 each; 1 of 3 files
with one verdict on every run; 1 rule cited beyond the checkers in 15
reviews, the pinned one, on the plain script.
Reading the first run found the eval miscounting. file_finding_count
kept advisory hits, though its comment said live findings only, so it
read 13 where check prints 10 and fieldtest counts 10. It counts live
findings now, and advisory ones in a field of their own that the report
prints the way check does. The committed run is the second, made after
that and the citation fix in the previous commit (ARCHITECTURE 3.74).
figures.py reads both eval runs. check.* comes from the check rows, and
--check fails on a row for a file the repository nβ¦
layers: a citation written as a list under the label is a claimβ
cited_ids read the 'Rules applied:' line from the colon on. The prompt
asks for the ids comma separated there, and the 7B also writes the label
on its own with the ids listed under it, one per line. Read from the
line, that list was an empty claim: the knowledge layer printed "the
answer cites no rule ids" under an answer citing four, and returned
UNKNOWN without asking whether any of them exist. An invented id in such
a list was never looked at, where on the line it is a critical finding
and a FAIL.
When nothing follows the colon, the list directly under the label is
the claim, up to the first line that is not a list item. Code blocks are
still never a source, and a rule named under 'Not handled:' is still not
a claim. corpus.report_citations, behind nexa verify, kept its own copy
of the parse with the same gap, and calls cited_ids now.
The recorded answer is a fixture, LOAD_PRICES_BULLETED, from nexa check
on subjects/etl/load_prices.py. Tests pin it, an invented id in its
list, whβ¦
fieldtest: a file two checkers could not read is one fileβ
The report's "N of them the checkers could not fully read, so the rate
below is not about all M" took N from the rows of not_fully_checked, and
there is a row per gap, so a file with a depends gap and a constrains
gap counted as two files. On a one-file tree that read "2 of them ...
not about all 1". N is now the number of distinct paths, and the list
under it still shows every gap. A test pins it.
No artifact stores these rows, so no published figure moves. The
docstring of semantics._note_unreadable said fieldtest counts a repeated
gap twice as a file; it says listed twice now. The SKIP_DIRS comment in
this file loses its "27 rules", as the other four did in facddfd.
794 passed, 2 skipped; every marked figure agrees.
readme: the 49/57 in the core sample table is OpenSPP'sβ
README's core sample table gave stored-compute-needs-complete-depends a
previous figure of "49/57 OCA". 49 true of 57 decided is OpenSPP's
reading (classification-openspp-2026-09.json: 49 true, 8 false,
3 undecidable), the same numbers README's second-ecosystem table prints.
OCA's was 41 of 41 decided when the row was written in 210b09e, and is
42 of 42 since 03128a2. The row now says OpenSPP. ARCHITECTURE 3.52 is
dated, so it gets a corrected note under its table rather than a rewrite.
Four comments still gave the corpus as 27 rules, which it has not been
since e9d9bcb: bin/context.py, bin/subject.py and two in bin/eval.py.
They lose the number rather than gain a new one, since nothing keeps it
current. eval.py's retrievability docstring also said nine queries at
top 6 could not have reached most of the corpus; nine times six is 54
places for 27 rules, so it now says what is true, that the sample is
too small to say anything about a rule that did not appear.
794 passed, 2 skipped; eβ¦
depends: a read through a method's own output, measured and kept exemptβ
The exemption skips every read rooted at a compute's own output. Odoo
18 drops a field from its own @api.depends (fields.py 842-846), but
keeps a path through it: @api.depends("team_id.member_ids") on
team_id's compute re-runs it when the team's members change. So the
exemption was measured narrowed to the bare field, against a fresh
baseline at 0bc85c5 that reproduced the committed artifacts: OCA 101
and OpenSPP 161 unchanged, core 2357 -> 2394, nothing removed.
All 37 read, every one a compute keeping its current value while it
still fits and reading that value to decide. 17 true as the rule
states it, 16 of them smart defaults (readonly=False): crm lead and
lead mining teams (member_ids, user_id), crm and project stages (team,
project_ids), hr_contract structure type country, accrual plan time
off type, maintenance team and technician company, reconcile model
journal type, tax group country and company, product purchase unit
category, chatbot triggering answers' step sequence. The β¦
depends: Odoo refuses neither a self-dependency nor a path ending .idβ
The depends checker's docstring, and two dated passages (ARCHITECTURE
3.18's first-run table, README's "What the first run found"), said the
ORM refuses both entries the checker stopped asking for. Odoo 18.0 at
3c3e3b3 refuses neither.
A field in its own @api.depends is dropped without a word.
odoo/fields.py 798-855, Field.resolve_depends: the field is found in
_fields, so the ValueError at 814-820 does not fire; the recursive
warning at 821-823 needs index > 0; and 842-846 skip it ("do not make
self trigger itself"), yielding only paths through it, so F.x is kept,
as in the comment's line_ids.foo example. get_depends (557-594) collects
without validating. Triggers come only from resolve_depends
(odoo/modules/registry.py 497-520), and recompute and onchange read only
triggers (models.py 7176-7228 modified, 7384-7390 _has_onchange through
get_dependent_fields), so the entry does nothing at runtime. A
readonly=False compute is not recomputed over a user's write: there is
no trigger, andβ¦
Merge origin/main: the depends sweep (10a, 10b) and 3.72β
README.md's coverage table conflicted on adjacent rows: kept this
branch's re-measured retrieval row and main's re-measured OCA and
OpenSPP rows. figures --check passes, 288 of 288.
depends: what a file says a method computes is its output; a lambda stays unreadβ
TODO 10a asked whether a read inside a lambda is a dependency. Binding the
parameter of x.filtered/mapped/sorted/grouped(lambda r: ...) and of
sorted/max/min(x, key=lambda r: ...) to x's records is exact, and it was
built and measured against fed0255: depends OCA +8, OpenSPP +1, core +127;
constrains OCA 0, OpenSPP 0, core +27. All read, and they do not read true.
Core depends 40 of 127 true, 28 of those smart defaults; constrains 9 of 27;
OCA 2 of 8; OpenSPP 1 of 1. A filter mostly tests a field that says what
kind of record this is. Of core's 87 false depends findings, 28 are an
identifier or type nothing changes (six display_type tests write()
refuses), 12 a guard on which records to touch, 12 a compute another module
declares unstored, 19 covered by a merged parent decorator, a transitive
compute or a field that always changes with them, 9 the method's own output,
7 alone. Constrains: 11 name a non-stored related or computed field Odoo
logs as not writeable (country_code seven timeβ¦
depends: what a string names and what a subscript picks are readsβ
mapped()'s siblings now read the fields they name in a string literal,
through _named_reads (was _mapped_reads): filtered("a.b"), grouped("f"),
sorted("f") and, on Odoo 19, an order such as "date desc, id", and the
leaf fields of a filtered_domain() literal. _attr_path and
_resolve_iterable look through a subscript whose index is a literal
integer or a slice, so rec.line_ids[0].amount reads line_ids.amount;
record[name] and x[i] still stop the path. Every exemption applies to
the new reads. constrains-must-list-every-field-read makes the same
reads, and depends-must-not-name-unread-fields counts them when it
steps aside, which moved nothing.
OCA 99 -> 101, OpenSPP 157 -> 163, core 2338 -> 2358, nothing lost and
no other rule moved. All 28 read: 18 true, 10 false.
OCA, 2 true (account_move_name_sequence's journal hash flag,
account_spread_cost_revenue's line_ids), into classification-oca.
OpenSPP, 6 true (five spp_programs registrant counts filtered on
is_registrant, spp_approval's tieβ¦
readme: the coverage figures nothing was countingβ
Two rows in the coverage table had drifted since ARCHITECTURE 3.62
added a 28th rule, and the stale ORM count sat elsewhere too:
- 19 Odoo ORM rules is 20 (build_corpus prints 20 + 8). Fixed in the
table, the corpus intro, the plain-script passage, and at its source:
nexa check's note on a plain script hard-coded "the 19 ORM rules"
and now counts the rules about to run by the prefix on each id. The
README quotes what it prints now, and nexa help check no longer
states counts it cannot keep true.
- Recall under meaning-preserving rewrite, 79/79, is 82/82 (nexa
adversarial at 7dbbe3a; the three new rewrites are the 28th rule's).
- Retrievable by their own probe, 27 of 27 at top 6 and 25 at top 3,
is 28 of 28 and 26 (nexa eval --retrieval at 7dbbe3a, on an index
whose embedded text is unchanged since 3.62; the two misses at 3 are
commit-inside-a-loop and eval-and-pickle, as 3.62 recorded).
figures.py gains corpus.origin.odoo and corpus.origin.python, and the
ORM and Pyβ¦
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β¦
onchange: the same check made again on the server backs itβ
An onchange is backed when a method of its own class that the server
always runs (create, write, a field's inverse=, an @api.constrains
method) makes the same check: the exact message the onchange raises,
raised again (20 characters or more), or the same check-shaped helper
called where the onchange hands it a watched field. A paraphrase, an
action_ method and posting do not count, so l10n_ar's journal pairing
stays reported.
OCA 99 and OpenSPP 157 unchanged. Core 2339 -> 2338: mrp.workorder's
date_finished onchange, whose sentence _set_dates (the inverse of both
date fields) raises too; read as false in 3.67. The API key wizard was
already out, since its onchange only warns. Nothing else moved. 280 of
280 hand-classified true positives still reported. ARCHITECTURE 3.69.
onchange: backed by a constraint on its field, refused only when taken backβ
Two changes to onchange-is-not-validation, shipped together because the
first reads true only with the second. A constraint backs an onchange
only when it covers a field the onchange watches (models.Constraint and
_sql_constraints by the names in their SQL, @api.constrains by its plain
names, unreadable ones cover everything); before, any constraint in the
file cleared it. And a returned warning dict counts as a refusal only
when its branch also writes a watched field or clears a field on the
record; a warning beside no write is a notice the user saves past in
the form as in an import. A raise still counts on its own.
OCA 99 unchanged (account_loan's rate note stays out). OpenSPP 153 ->
157, all read into classification-openspp-rest: spp_area level cap,
CR type required documents, one head per group true; spp_import_match
false, backed by the parent's constraint. Core 2345 -> 2339: +11 (8
true: four partner blocks, loyalty expiry, negative price rounding,
expense edit rights, a componβ¦
onchange: a dead _sql_constraints backs nothing on 19β
onchange-is-not-validation no longer counts a _sql_constraints block as
the backstop when the tree pins Odoo 19 or later, where the registry
never creates it. 16 to 18 and an unknown series keep counting it.
OCA 99, OpenSPP 153, core 2345: unchanged. OpenSPP's onchange files
with a constraint all use models.Constraint or @api.constrains, and so
did the file before its own incident fix (208d9758).
The other half of the proposal, counting a constraint only when it
covers a field the onchange watches, was measured and not taken. It
added 36 (OCA 1, OpenSPP 4, core 31), all read, 11 true: spp_area's
level cap, CR type required documents, the one-head rule, four partner
"block" warnings, loyalty expiry, negative price rounding, expense edit
rights, a component equal to its product. 4 false are checked on the
server elsewhere; the other 21 are warning dicts that inform and refuse
nothing (account_loan's rate note among them). The file-wide guard was
hiding that the warning test is too wide.β¦
x.mapped("a.b") with a literal string now reads as x.a.b in
stored-compute-needs-complete-depends. sum(rec.invoice_ids.mapped(
"amount_residual")) under a bare @api.depends("invoice_ids") was silent
while the generator spelling of the same read was reported. The
receiver resolves through filtered/sorted/sudo as a loop's iterable
does; every existing exemption applies (.id, the method's own outputs,
non-stored computes, wizards, display_name, declared dotted prefixes),
and a mapped() inside a raise or log line is skipped. A lambda or a
name stays unread.
OCA 99 and OpenSPP 153 unchanged: their 3 + 3 undeclared literal
mapped() reads are all display_name, a wizard or non-stored. Core
2336 -> 2345, nothing lost. All 9 read: 8 true as the rule states it
(pos_sale x3, survey scoring, peppol and nemhandel response_code,
purchase_stock and sale_stock incoterm_location, the last two on a
readonly=False default), 1 false (sale_mrp, fields declared unstored in
sale_stock). 277 of 277 hand-classβ¦
sudo: a public page reading what it publishes is re-scopedβ
A read under sudo() inside a route(auth='public', website=True) counts as
re-scoped when its domain ANDs ('website_published', '=', True) at the
top level. Publishing is the owner's decision to show a record to
everybody, and on website.published.multi.mixin the leaf's search method
adds the current website's domain. The domain is read as prefix notation:
a leaf under | or !, a fragment glued on with a dangling operator, or an
insert() keeps the finding, and a name counts only when every plain
assignment carries the leaf. is_published, a flag compared with anything
but True, and model methods stay findings, so set_sequence_up is still
reported.
OCA and OpenSPP unchanged. Core 2350 -> 2336: 12 of the 14
public-website-read false positives from 0e2212c go (/customers,
/partners); the forum redirect (id from the URL) and the combo helper
(not a route) stay. Two older /members findings also went; both read,
same shape. A partner published with no website_id shows on every
company's page, β¦
sudo: a domain on records the caller already holds is re-scopedβ
A sudo() call counts as re-scoped when a domain leaf pins it, with =, in
or child_of, to ids of records the caller obtained under its own rules:
self reached through subset-returning calls (filtered, mapped, search,
...), the user's own records, a name or loop variable bound only to
those. Not held: caller-supplied ids, anything under sudo(), browse() and
env.ref(), and any other method on self (a controller's helper was being
trusted by its root until reading caught it). !=, < and not in never
count, so set_sequence_up is still reported.
OCA and OpenSPP unchanged. Core 2422 -> 2350: 14 of the 44 false
positives from 0e2212c go (11 domain-scopes, 3 guards); the 7 left in
that class take sudo'd or caller-supplied ids. It also removed 58 older
findings nobody had read; all were read and none is a defect: 20 guards
that must see every company, 21 records of held records, 9 the user's
own, 2 deliberate syncs of one shared record. Partners and products are
shared across companies, so this β¦
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β¦
sudo: re-scoping has to re-apply every rule everybody getsβ
A sudo() call counted as re-scoped when its arguments named a field of
any one of its model's record rules. Odoo ANDs global rules (no groups)
for every user and ORs group rules per audience, so naming a group
rule's field re-applies that rule only. The tree facts now keep each
rule apart with its audience (context.model_rule_sets), and a call is
re-scoped only when it names a field of every global rule. Models with
no global rule keep the old test.
It can only add findings. OCA: none. OpenSPP: 19, all on lines carrying
OpenSPP's own nosemgrep sudo marker, so decided and out of every rate.
Core: 45, read in full into classification-sudo-core-2026-09.json:
1 true, website_sale's set_sequence_up (its twin set_sequence_down uses
a plain search), and 44 false that the old test let through by naming a
group rule's field (employee_id, move_type, website_published, and the
res.partner portal rule's `id`). Core 2380 -> 2420 live. Recorded as a
cost: the largest false class (18, a domain on idβ¦
depends-unread: an override that calls super() adds triggers for the chainβ
A compute whose body calls super() is exempt. Odoo merges @api.depends
across the MRO (Field.get_depends extends one list with every override's
_depends), so the fields an override names are read by the parent's body,
reached through super(). Core writes it in its own comments: "EXTENDS
'web' to add dependencies". ARCHITECTURE 3.57 gave the reverse reason
("replaces"); 3.59 corrects it.
The constant-only stub was tried as a second exemption and withdrawn: it
lost three true positives on OpenSPP and OCA. A test pins that it stays a
finding.
OCA and OpenSPP unchanged. Core 2420 -> 2380: all 40 findings that left
were read, and every one is an override delegating to super() (partner
and employee avatars, four need_cancel_request computes, the Czech and
Polish currency-rate computes, and the rest). Three were in core's read
sample, which is now 79: 42 true, 37 false.
274 of 274 hand-classified true positives still reported. ARCHITECTURE 3.59.
narrow: the last three named false-positive classes, and one weakness found on the wayβ
check-company: the related= and computed-Many2many shapes the worklist
named had shipped in 3.43. The one live false positive left was a SQL view
(OpenSPP fund_report, _auto = False), now exempt as ondelete already was.
On core that also clears six report views.
sudo on registry models: core gives record rules to five ir.* models
only, so ir.model, ir.model.fields, ir.model.data and ir.module.module are
exempt by name; a tree without base could never answer for them. self now
reads as the enclosing class's model. ir.attachment is kept off the list,
because its _search filters by access and sudo drops that.
read only to phrase a message: OpenSPP's five live cases were all a log
line, and core's one a message collected in a list and raised joined.
Both are exempt; a read in the condition as well is still reported.
Every finding that left was read. OCA 103 -> 98, OpenSPP 154 -> 148, core
2455 -> 2420. On core, resolving self made one call reachable by an older
weakness: re-scoping acceβ¦
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, β¦
figures: every present-tense number, checked against the artifact it came fromβ
bin/figures.py recomputes every figure the README and ARCHITECTURE state
about the present from the committed artifacts, and --check fails on a
marked figure that disagrees. Runs in CI as its own job, no checkout needed.
The drift it was built for was real: OCA's rate read 3.0 in the README
introduction, 3.3 in the summary table and 2.9 in ARCHITECTURE 6, and the
table's recall row still said 1 of 56. Dated sentences are left as the
record they are.
Fieldtest artifacts regenerated and now record checkout commits; core's is
new. Recall artifacts committed for the first time, reproducing 20 of 334
exactly. "244 of 244" becomes 271 of 271 across six classifications,
computed rather than asserted. The python classification's summary said 26
true over rows holding 27; the 27th is the consent-receipt finding 210b09e
un-suppressed, so the composed-in rules are 53%, not 52%. ARCHITECTURE 3.53.
24 days ago
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β¦
evidence: the headline number gets the artifact every other number hasβ
The 10% that opened this README was the one figure in the project resting on
an assertion. Every other claim shipped with the JSON behind it and a command
that regenerates it; this one existed as prose and nothing produced it.
So all 448 fix commits in OpenSPP were read. Not matched -- read. A keyword
pass was tried first and scored 28% of the tree as review-caught, roughly half
of them wrongly, because `review` is a business object in spp_approval and the
word means two things in one repository. That is the whole reason the verdicts
are hand-written and the script only checks them.
bin/surfaced.py re-derives the commit set with recall.py's own selector, fails
on any drift between history and the artifact, and recomputes every published
percentage from the rows. A number in the README now traces to the rows it came
from rather than to a memory of having counted once.
The number moved, and upward: silent is 17.0% of all 448 fix commits and 25.8%
of the 295 defect fixes whose body sayβ¦
evidence: prune by the time in the bundle, not the time on the inodeβ
CI went red on a test that had passed on this machine every time: eight
bundles written with NEXA_EVIDENCE_KEEP=3, and the three left were t-03, t-04
and t-06. Not the newest three, and not a stable wrong answer either.
_prune ranked bundles with os.path.getmtime. Linux stamps inodes from a clock
that only moves once a tick, so all eight writes carried one identical mtime.
sort() is stable, so the tie fell through to os.listdir order -- hash order on
ext4 -- and the tail it unlinked was an arbitrary subset. That run deleted
t-07, the bundle write() had that moment written, and write() returned its
path regardless: a run printing an evidence id for a file already gone, which
is the promise _keep_count already had to stop NEXA_EVIDENCE_KEEP=0 from
breaking. Windows stamps at sub-millisecond resolution, which is why no
version of this has ever failed here.
The bundle carries its own time at microsecond resolution, and it is the time
the listing already shows people. _written_at reads itβ¦
external: run somebody else's analyser, and put a number on their rulesβ
crosswalk reads another ruleset's PATTERNS rather than running it, because
"semgrep is not installed here and adding it would make an offline tool need
a package index". That reasoning conflated two things. Running another
analyser costs `stdlib only`. It does not cost OFFLINE -- pylint runs on this
machine, OpenSPP's lint scripts are Python in their own tree, and nothing
leaves the laptop either way.
So bin/external.py shells out rather than importing. subprocess and json are
stdlib, the analyser is optional, and an absent one is a gap -- the same shape
as the OpenTelemetry bridge in telemetry.py.
nexa fieldtest --stack odoo . --with-json their-lint.json --with-name openspp
Their findings become SourceHits: namespaced so an external id can never be
mistaken for one of ours, mapped conservatively (their `error` is our `high`,
never `critical`, because critical here is a judgement somebody made about a
rule), obeying the same suppression convention, and counted in the same
per-klβ¦
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β¦
banner: solid NEXA in two hues, over an amber barβ
Candidate 4. The framed NX it replaces was unreadable at a glance -- the
half-block glyphs are sub-cell shapes, and a terminal font renders them as a
cluster of coloured squares rather than as strokes. Solid stems fix that: an
outline reads as a logo, a filled stem reads as a lit tube.
Two rows of wordmark, one bar:
ββ β βββ βββ βββ 51 cyan
β ββ βββ β β βββ 201 magenta
ββββββββββββββββ 214 amber
TWO HUES, NOT FIVE
A neon sign is one or two colours of gas. The five-step ramp swept cyan, ice,
white, pink, magenta and read as a rainbow rather than as a tube, and the
intermediate steps were doing the work the letterforms should have been doing.
The bar is the contrast rather than a third hue in the sweep: amber is what the
rest of the CLI already uses for attention, so it is warm under something cool
and is not a new colour in the program. It fades β to β to β left to right, which
is what makes it read as light thrown by the sign rather than a rule drawnβ¦
report: findings in likelihood order, because history has an opinionβ
The cheapest of the three and it needed no new measurement. The grades were
graded once, at build time; the report was throwing them away.
Findings printed in line order say nothing about which rule is worth reading.
Line 4 came before line 90 whether line 4 was a preference and line 90 was the
bug. So the grade leads, and line order survives inside it -- a run of equally
likely findings still reads down the file.
sudo-bypasses-record-rules 13 on-target of 41 read first
prefer-sql-constraint 0 on-target of 49 read last
`check` prints both axes as columns, so the split from the severity commit is
visible at the point it is useful rather than only in the schema:
impact likelihood
critical defect line 96 odoo.check-company-on-relations
critical unmeasured line 214 odoo.constrains-must-list-every-field-read
high construct line 72 odoo.ondelete-is-a-decision
`fieldtest` prints the tally per grade. On OpenSPP: β¦
triggers: the two conditional rules learn to ask the treeβ
`ondelete` and `check-company` both describe a harm that needs a precondition,
and neither could see whether the precondition held.
ondelete the harm is a read through a reference nulled when the PARENT
was deleted. Master data nobody deletes cannot produce one.
check-company the harm is company A's record pointing at company B's. A
codebase running one company has no boundary to cross.
Both are answerable from the tree, and answering them turns a rule describing a
CONSTRUCT into one describing a DEFECT -- for the subset where the answer exists,
and only there.
present promoted to a defect, and the finding says why
absent no mechanism here; no finding is made
unknown nothing claimed, and the rule keeps the grade it had
The third row is the one that matters. A default would be a second severity: a
field carrying a judgement nobody made.
WHAT THE EVIDENCE IS
For deletion: an unlink() call resolved to a model, an unlinkβ¦
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β¦