|
Download ARCHITECTURE.md from DBax127/nexa: direct link, hf CLI and curl.
- Browser
- Download file 311 kB
-
https://huggingface.co/DBax127/nexa/resolve/main/ARCHITECTURE.md
- Command line
-
hf download hf://DBax127/nexa/ARCHITECTURE.md
-
curl -L -o ARCHITECTURE.md https://huggingface.co/DBax127/nexa/resolve/main/ARCHITECTURE.md
311 kB
| # Nexa β Architecture and Decision Record | |
| How Nexa works, every significant decision and the evidence behind it, and an honest | |
| account of what it cannot do. | |
| Decisions are recorded with the reasoning, including the ones that were **wrong first**. | |
| A decision record that only lists the final answer hides the most useful information. | |
| --- | |
| ## 1. Overview | |
| ``` | |
| question | |
| β | |
| βΌ | |
| ββββββββββββ embed on CPU, cosine over the corpus | |
| β retrieve β + pinned rules that bypass retrieval | |
| ββββββ¬ββββββ | |
| βΌ | |
| ββββββββββββ Qwen2.5-Coder-7B, 100% GPU, ~37 tok/s | |
| β draft β | |
| ββββββ¬ββββββ | |
| βΌ | |
| ββββββββββββ nine layers, cheapest first, short-circuited | |
| β verify β each reports a verdict AND what it could not check | |
| ββββββ¬ββββββ | |
| β nothing repairable ββββββββββββββΊ answer | |
| β repairable findings | |
| βΌ | |
| ββββββββββββ Qwen3-Coder-30B-A3B, ~11 tok/s | |
| β escalate β max_invocations = 1, enforced by topology | |
| ββββββ¬ββββββ | |
| βΌ | |
| verify βββββββββββββββββββββββββββββΊ answer + PASS / WARN / REJECT | |
| ``` | |
| ### The verify stage | |
| | # | Layer | Asks | Mechanism | | |
| |---|---|---|---| | |
| | 1 | syntactic | Does it parse at all? | `ast` + compile; delimiter balance for TS/JS | | |
| | 2 | structural | Is the answer the shape the prompt demanded? | required labels, leaked template text | | |
| | 3 | semantic | Does the code mean what it claims? | AST checkers, curated import check | | |
| | 4 | knowledge | Are the cited rules real, offered, and sourced? | citation scan against the corpus | | |
| | 5 | domain | Does the code obey the corpus? | `violation_pattern` over comment-free code | | |
| | 6 | context | Is it right *here* β this module, this series? | manifest, model names, tsconfig | | |
| | 7 | empirical | Does it hold up against this machine? | import resolution, bytecode, opt-in execution | | |
| | 8 | adversarial | Would the verdict survive a harmless rewrite? | meaning-preserving mutation | | |
| | 9 | provenance | Can the verdict be re-derived later? | the evidence bundle, checked for completeness | | |
| | β | decision | PASS / WARN / REJECT, and what was never checked | one stated policy, four separate confidences | | |
| **UNKNOWN is a result, not a failure.** A layer that cannot run says so, and the | |
| decision carries it forward as INSUFFICIENT EVIDENCE rather than letting it read as a | |
| pass. See Β§3.14. | |
| Three entry points, each independently useful: | |
| | Entry point | Gives you | Needs | | |
| |---|---|---| | |
| | `nexa ask` | grounded answer + all nine layers | Ollama + models | | |
| | `nexa check <file>` | the same, with a real context layer | Ollama + models | | |
| | `nexa run` | the above plus bounded escalation | + LLM Workflow Router | | |
| | `nexa report` / `nexa evidence` | measurement, and the record behind one verdict | nothing extra | | |
| --- | |
| ## 2. Hardware constraints that shaped everything | |
| Developed on a Lenovo ThinkPad P15 Gen 2: Xeon W-11855M, 32 GB DDR4-3200 in a **single | |
| channel**, NVIDIA RTX A3000 Laptop with **6 GB VRAM**. | |
| The 6 GB ceiling is the single most important fact about this design. It is why: | |
| - the working model is a 7B at Q4, not a 14B | |
| - the escalation model is a **Mixture-of-Experts** 30B (~3B active parameters) that | |
| lives in system RAM, rather than a dense model that would run at 1β2 tok/s | |
| - embeddings run on the CPU | |
| - the 30B is capped at `num_ctx 6144` | |
| ### Measured, not estimated | |
| | | | | |
| |---|---| | |
| | 7B, full GPU offload | **37β38 tok/s** | | |
| | 7B, Ollama's automatic split | **11.7 tok/s** | | |
| | 30B MoE, `num_ctx 6144` | **11.3 tok/s** | | |
| | 30B MoE, `num_ctx 8192` | **fails** β CUDA out of memory | | |
| --- | |
| ## 3. Decisions | |
| ### 3.1 Two single-stack models, not one dual-stack model | |
| **Decision.** Separate `ai-odoo`, `ai-ts` and `ai-js` rather than one validation model. | |
| **Why.** The first attempt used one system prompt covering both Odoo and TypeScript. | |
| Asked an Odoo question β *"I want to cap a sale order line quantity, I was going to use | |
| an onchange"* β it answered entirely in TypeScript with a Zod schema. A 7B does not | |
| reliably route between two stacks described in one prompt; the instructions bleed. | |
| **Consequence.** Extra personas are effectively free: all three share one 4.7 GB weights | |
| blob because Ollama deduplicates. Adding a fourth stack costs a Modelfile. | |
| **Do not merge them back.** | |
| ### 3.2 The corpus is data, and prompts are generated from it | |
| **Decision.** Rules live in `corpus/rules/*.json` against a JSON Schema. System prompts | |
| and the retrieval index are **generated** by `nexa build`. | |
| **Why.** Prompt tuning plateaued after two iterations. Hardening the Odoo | |
| `false`-for-empty-field rule fixed the scalar fields and simultaneously broke the | |
| Many2one tuple handling that had previously been correct. A 7B holds a bounded number | |
| of constraints; past that point, more instruction text trades one error for another. | |
| Moving the rules into data changed the failure mode. Instead of rewriting prose and | |
| hoping, a rule is added once and flows to the prompt, the retrieval index, and the | |
| compliance checker together. | |
| **Consequence.** `modelfiles/gen-*.Modelfile` are build artifacts. Editing them by hand | |
| is silently undone by the next build. | |
| ### 3.3 Rules carry a machine-checkable violation pattern | |
| **Decision.** Each rule may carry a `violation_pattern` regex, optionally cleared by a | |
| `satisfied_pattern`. | |
| **Why.** Citation checking proves an id exists. It cannot catch the failure that matters | |
| more: the model cites a real rule and then breaks it. Observed exactly that β the 7B | |
| cited `odoo.onchange-is-not-validation` above an `@api.onchange`. | |
| **Consequence.** 32 of 43 rules are checkable by pattern. Eight more are checkable by | |
| an AST pass (3.12) and the remaining three return no verdict rather than a false | |
| pass. Patterns match the code with comments stripped (3.13). | |
| ### 3.4 Patterns are tested against the rule's own samples | |
| **Decision.** `nexa validate` fails the build on three pattern faults: one that does not | |
| match its own `wrong` sample, one that fires on its own `correct` sample, and a | |
| `satisfied_pattern` that clears its own `wrong` sample. | |
| **Why.** Every rule already carried a wrong/correct pair, so the test data existed for | |
| free. A pattern that catches nothing is useless; a pattern that fires on good code is | |
| worse, because it trains you to ignore the checker. | |
| ### 3.5 Cross-checking measures the false-positive rate | |
| **Decision.** `nexa crosscheck` fires every pattern at every *other* rule's correct | |
| sample. | |
| **Why.** Self-testing proves a pattern handles its own case. It says nothing about | |
| whether it fires on unrelated good code. | |
| **Evidence.** The first run found three real defects: | |
| | Pattern | Defect | | |
| |---|---| | |
| | `float-comparison` | Fired on `qty > 0`, including inside SQL where `float_compare` does not apply. Narrowed to equality, the actual trap. | | |
| | `odoo-read-returns-array` | Fired on any `Schema.parse(await ...)`. Narrowed to Odoo `read()`. | | |
| | `ondelete-is-a-decision` | Correctly flagged a sample **in this corpus** that did not follow its own rule. Fixed the sample; excluded `related=` fields. | | |
| Now 0 cross-hits. | |
| ### 3.6 Some rules deliberately have no pattern | |
| **Decision.** Rules that no regex can decide carry no `violation_pattern`, and the | |
| reason is recorded in `NO_PATTERN_REASON` and shipped with the rule itself as | |
| `no_check_reason`. | |
| | Rule | Why not | | |
| |---|---| | |
| | `odoo.schema-change-needs-migration` | The violation lives in a diff between module versions plus the presence of a `migrations/` directory. Nothing in one snippet reveals it. | | |
| | `typescript.safeparse-when-failure-is-expected` | Depends on whether the caller can recover β context a snippet does not contain. | | |
| | `typescript.brand-validated-primitives` | Absence of branding is not detectable; plain strings are correct in most code. | | |
| Honest coverage with a named gap beats full coverage with patterns producing noise. | |
| The reason now travels **with the rule** rather than living only in the build script, | |
| so every layer that reports a gap can print why it exists. A coverage list without | |
| reasons is a number, and a number is what gets rounded up to "checked". | |
| Two rules left this table for Β§3.12. | |
| ### 3.7 Documentation is consulted when authoring, never at query time | |
| **Decision.** Rules carry a `source` URL to upstream documentation. That documentation | |
| is read while writing the rule, not while answering a question. | |
| **Why.** Query-time lookup needs internet, adds latency, and reintroduces the | |
| hallucination surface the corpus exists to remove. Authoring-time verification bakes | |
| checked knowledge into offline data. Validate once at the boundary. | |
| **Evidence.** Consulting the Odoo docs immediately surfaced a rule that had been missed: | |
| `@api.constrains` **silently ignores dotted field names**, while `@api.depends` accepts | |
| them. The decorators look interchangeable and are not, and the failure is silent β the | |
| module installs and the constraint simply never fires. Odoo's own wording on the | |
| load-bearing rule is blunter than the original draft: *"Never ever use an onchange to | |
| add business logic to your model... they are only triggered in the form view."* | |
| ### 3.8 Pinned rules bypass retrieval | |
| **Decision.** Rules flagged `"always": true` are injected into every answer for their | |
| stack regardless of similarity score. | |
| **Why.** Retrieval is driven by the question, but some traps only become relevant once | |
| the model picks an approach. "A CHECK constraint cannot read another table" cannot be | |
| surfaced by similarity against a question about stock levels β the question says nothing | |
| about CHECK constraints. The rule became relevant only because the model reached for SQL. | |
| This is a real limit of retrieval, not a tuning problem. | |
| ### 3.9 Escalation is bounded by topology, not a retry counter | |
| **Decision.** `nexa run` routes its transitions through | |
| [LLM Workflow Router](https://gitlab.com/dobybaxter127/llm-router). The `escalate` | |
| container has `max_invocations=1`. | |
| **Why.** The bound becomes declared configuration rather than control flow buried in a | |
| function. Changing the policy means editing the topology, not the pipeline. The engine | |
| is content-blind β it sees container, depth and requested transition, never the question | |
| or the answer β so the decision stays deterministic and independently testable. | |
| **Evidence it earned its place.** The router's config validator rejected the first | |
| topology written here: a `verify`/`escalate` cycle containing `allow_reentry=False` is | |
| dead by construction. Caught at load time, before any model ran. | |
| **Locating it.** The checkout is found by search, not by one hardcoded path: | |
| `LLM_ROUTER_PATH` if set, then `llm-router-main` or `llm-router` beside this | |
| repo or next to it, then the home directory and Desktop. The original single | |
| default pointed at `~/Desktop/llm-router-main`, which was where it sat on the | |
| development machine and nowhere else -- a clone with the checkout beside it | |
| failed, and the failure stayed invisible for a whole session because a stray | |
| `LLM_ROUTER_PATH` in the environment was masking it. A failure now names every | |
| path it tried rather than the one it was told about. | |
| **API note.** `invocation_depth` carries entries made *before* the current one. The | |
| engine refuses on `depth >= max_invocations`, so incrementing before evaluating makes a | |
| `max_invocations=1` container refuse its own first call. | |
| ### 3.10 Telemetry is JSONL first, OpenTelemetry second | |
| **Decision.** Events are written as JSONL under a `localai.*` namespace. The OTel bridge | |
| is optional and no-ops when `opentelemetry-api` is absent. `nexa run` uses the router's | |
| own `ObservableEngine`. | |
| **Why.** The questions worth asking are aggregate β does escalation pay for itself, | |
| which rules do the models keep breaking, is retrieval surfacing the right thing. Those | |
| are queries over a table. A trace waterfall answers "where did this request spend time | |
| across services", and this is one process on one laptop. Running a collector and backend | |
| here would spend RAM competing with the model being measured. | |
| The OTel seam stays because the cost is one line and it matters the moment this runs | |
| inside a larger service. | |
| **Conventions borrowed from the router**, because they were right: owned namespace, zero | |
| hard dependency, and **a violation is not an error** β the same distinction the router | |
| draws for REFUSE. A detected violation means the checker worked. Status stays OK and the | |
| outcome is an attribute. | |
| ### 3.11 Content is not recorded by default | |
| **Decision.** Question and answer text are omitted from telemetry unless | |
| `LOCALAI_TELEMETRY_CONTENT=1`. | |
| **Why.** This runs on a machine used for client work. A telemetry file is the last place | |
| client code should accumulate. Recorded instead: rule ids, counts, model names, timings. | |
| ### 3.12 Two critical rules moved from regex to AST | |
| **Decision.** `odoo.constrains-must-list-every-field-read` and | |
| `odoo.stored-compute-needs-complete-depends` are decided by checkers in | |
| `bin/semantics.py`, declared on the rule as `semantic_check`. | |
| **Why.** Β§3.6 named both as the strongest case for an AST pass: both `critical`, both | |
| silent failures, both needing the same thing β a set comparison between what a | |
| decorator declares and what the method body actually reads. Python's `ast` module | |
| does that without leaving the standard library, which is the only reason it was | |
| affordable. | |
| The walk resolves aliases to a fixpoint, so `for rec in self` followed by | |
| `for line in rec.line_ids` makes a later `line.price` resolve to the dependency | |
| `line_ids.price`. Reads inside a `raise` are excluded: a field read to phrase an | |
| error message does not decide whether the invariant holds, so it cannot decide when | |
| the constraint has to re-run either. | |
| **Evidence it needed the same discipline as a pattern.** Cross-checking the new | |
| checkers against every other rule's `correct` sample found two hits on the first run. | |
| One was checker breadth β `rec.display_name` read only inside a `ValidationError` | |
| message β and narrowed the checker. The other was real: the `correct` sample of | |
| `odoo.check-constraint-cannot-cross-tables` read `company_id` without declaring it, | |
| so a sample in this corpus did not follow another rule in this corpus. The sample was | |
| fixed, exactly as in Β§3.5. | |
| **Consequence.** Machine-checkable coverage is 40 of 43 (93%): 32 by pattern, 8 by | |
| AST. `nexa coverage` reports the two mechanisms separately, because a rule that | |
| compares meaning and a rule that compares text are not the same kind of checked, and | |
| one percentage would hide which of the two a `critical` rule is resting on. | |
| **Carrying both is a hard error.** A rule with a `violation_pattern` *and* a | |
| `semantic_check` fails `nexa validate`: the domain layer defers to the semantic | |
| layer, so the pattern would be silently ignored. | |
| ### 3.13 Patterns match comment-free code | |
| **Decision.** `violation_pattern` and `satisfied_pattern` are matched against the | |
| generated code with its comments stripped. | |
| **Why.** A regex cannot tell code from prose, and a `satisfied_pattern` exists to | |
| clear a violation when the real fix is present alongside it. If the fix can be | |
| *mentioned* rather than applied, the checker reports clean while the bug ships. | |
| **Evidence.** Not a hypothetical. The counterexample probe builds its attack out of | |
| each rule's own `correct` sample β the real fix, quoted in a comment instead of | |
| applied β and on its first run **all 12 satisfied patterns in the corpus were | |
| defeated by a comment**, four of them on `critical` rules. Writing an `@api.onchange` | |
| and adding `# _sql_constraints = [` was enough to clear | |
| `odoo.onchange-is-not-validation`, the rule this project opens with. | |
| It cuts the other way too. A commented-out `@api.onchange` was being reported as a | |
| live violation, which is the false positive that teaches people to ignore a checker. | |
| **Consequence.** 0 of 12 defeated after the change. The stripper is quote-aware, so a | |
| `#` inside a string and a `//` inside a URL are left alone, and it preserves newlines | |
| so multi-line patterns still see the line structure they were written against. | |
| ### 3.14 Validation is layered, and UNKNOWN is a result | |
| **Decision.** Every answer 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 | |
| tenth step combines them. | |
| **Why.** Two checks were doing the work: do the cited ids exist, and does the code | |
| break a pattern. Both survive here 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. | |
| The more important reason is the one a flat checklist gets wrong. A list of checks | |
| invites you to build the cheap ones, tick every box, and read the result as | |
| validated. The cheap layers are cheap because they prove least. So a layer that | |
| cannot run reports `UNKNOWN`, and the decision carries that forward as INSUFFICIENT | |
| EVIDENCE. It never degrades into PASS, because *I found nothing* and *there is | |
| nothing to find* are different claims and only one of them is supported. | |
| **Consequence.** Confidence is reported as four separate words β structural, | |
| semantic, contextual, empirical β and never averaged. An average is high whenever the | |
| cheap layers ran, which is exactly the false reassurance the separation exists to | |
| prevent. A PASS prints with the sentence *PASS means the checks that ran found | |
| nothing. It does not mean the answer is correct.* | |
| **Ordering is a cost decision.** Layers run cheapest first and short-circuit: code | |
| that does not compile is not worth pattern-matching, so the layers below it report | |
| UNKNOWN with that as the reason rather than emitting artefacts of a broken parse. | |
| **A repair is not an error.** An Odoo answer very often shows a method at the | |
| indentation it had inside its class, and a bare `ast.parse` calls that *unexpected | |
| indent*. `parse_fragment` dedents, drops labelled sections that leaked inside the | |
| fence, and finally tries wrapping the block in a class β and **records every repair | |
| it made** as a medium finding rather than hiding it. Reporting a correct answer as | |
| uncompilable is the worst failure a syntax layer has available: it is wrong, it is | |
| loud, and it masks every layer beneath it. | |
| ### 3.15 The checkers are attacked as part of the build | |
| **Decision.** `nexa adversarial` runs three probes, and `nexa build` runs them. | |
| | Probe | Question it asks | | |
| |---|---| | |
| | `evasion` | Reformat the `wrong` sample without changing its meaning. Does the checker still see it? | | |
| | `counterexample` | Can a `satisfied_pattern` be cleared by a comment quoting the fix instead of applying it? | | |
| | `contradict` | When one rule fires on another's `correct` sample, is that intended breadth, or do the two rules disagree? | | |
| **Why.** `validate` compares a checker against code written alongside it, by the same | |
| hand, in the same sitting. Of course it passes. `crosscheck` adds unrelated good | |
| code. Neither asks what it takes to get bad code *past* a checker, which is the | |
| question that decides whether the checker is worth trusting at all. | |
| **Evidence.** The first run found the comment hole in all 12 satisfied patterns | |
| (Β§3.13) and one pattern losing its own violation under a trailing-comment rewrite. | |
| Current state: 64/64 rewrites still caught, 0 of 12 satisfied patterns defeated, 0 | |
| contradictions. | |
| **The phantom finding is recorded too.** The noupdate rule looked evadable until it | |
| turned out the mutation was appending `# noqa` to **view XML**, which is not a | |
| meaning-preserving rewrite but corruption. The comment marker now follows the | |
| language. A mutation that changes behaviour makes a disappearing violation ambiguous, | |
| and the entire value of the probe is that it is not. | |
| ### 3.16 Escalation fires on repairable findings, not on every finding | |
| **Decision.** `nexa run` escalates when the syntactic, semantic, knowledge or domain | |
| layer reports a `critical` or `high` finding. Structural, context, provenance, | |
| empirical and adversarial findings do not trigger the 30B. | |
| **Why.** A missing `Not handled:` label is a real finding that no larger model fixes | |
| better; it would just cost 11 tok/s to find that out. A tsconfig with `strict` off is | |
| not something a model can rewrite its way out of at all. The number that decides | |
| whether the 30B earns its load time is the rescue rate, and that number only means | |
| something if escalation fires on cases a rescue was possible for. Escalating on | |
| cosmetics inflates the denominator with cases that were never winnable. | |
| Amended 2026-10-01: a `critical` or `high` finding that is not advisory. An advisory finding | |
| states a preference, which leaves a larger model nothing to repair, and until Β§3.81 it | |
| escalated anyway. | |
| ### 3.17 Each run writes one evidence bundle | |
| **Decision.** A run writes `bin/evidence/<trace>.json`: the corpus hash, the rules | |
| retrieved with their scores and which arrived by pin, every layer's verdict and | |
| findings, the checker versions, the model and its options, and the timings. | |
| **Why.** Telemetry answers aggregate questions and is deliberately not addressable β | |
| there is no way to pick up one past answer and ask why it was marked VERIFIED. A | |
| verdict nobody can reconstruct is a claim, not a result. Same argument as Β§4's | |
| closing line: an unmeasured number decays into a claim. | |
| **Consequence.** The provenance layer checks the bundle rather than the answer, and | |
| fails when a field needed to re-derive the verdict is missing. Content follows the | |
| telemetry policy: question and answer text are omitted unless | |
| `LOCALAI_TELEMETRY_CONTENT=1`. Bundles are gitignored and pruned by count. | |
| --- | |
| ### 3.18 The checkers are measured against code nobody wrote for them | |
| Β§3.4, Β§3.5 and Β§3.15 all compare a checker against the 32 samples in this corpus, | |
| written by the same hand in the same sitting. Passing proves self-consistency. | |
| `nexa fieldtest <folder>` fires every checker at a tree of somebody else's source and | |
| reports the hit rate per thousand lines. On code that already works, a hit is a | |
| question about the checker. | |
| Run against OCA/server-tools and OCA/account-financial-tools β 606 files, 34,550 | |
| lines β it took the corpus from 397 hits (11.3/kloc) to 293 (8.5/kloc) by narrowing | |
| three checkers that were wrong in ways the 32 samples could not expose: | |
| | Checker | Before | After | What it was doing | | |
| |---|---|---|---| | |
| | `onchange-is-not-validation` | 16 | 0 | Firing on any `@api.onchange`. None of the 16 was validating anything β they set defaults and narrow domains, which is what onchange is *for*. It now requires the onchange to try to enforce something. | | |
| | `depends_covers_compute_reads` | 121 | 95 | Demanding `@api.depends` entries the ORM refuses: paths ending `.id`, and fields the method computes itself. It was telling authors to write a decorator Odoo would reject. | | |
| | `prefer_sql_constraint_applies` | 8 | 2 | Reading a guard as the whole rule. In `if record.storage == "url" and not record.url:` the first half is an `ast.Compare` and the second is not, so it concluded the function "does nothing else". | | |
| Corrected 2026-09-27: the ORM refuses neither entry in the `depends_covers_compute_reads` | |
| row. Odoo 18 drops a field from its own `@api.depends` without a word, and keeps a path | |
| ending `.id` as the relation it ends on, with a trigger on `id` that fires when a related | |
| record is deleted (`resolve_depends` in `odoo/fields.py`, 18.0 at 3c3e3b3). | |
| `ondelete-is-a-decision` took two passes. Odoo defaults a *required* `Many2one` to | |
| `ondelete='restrict'` β the behaviour the rule recommends β so 56 of its 198 hits were | |
| fields already correct and were exempted. That left 142 optional `Many2one`s genuinely | |
| defaulting to `set null`: true positives by the rule's letter, at 4.1/kloc, and still | |
| loud enough to bury the other nine Odoo rules under one that is a policy rather than a | |
| defect. | |
| The second pass asked where the harm actually lands. `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", which is | |
| the rule's own wording and needs a read to happen at all. A field declared and never | |
| dereferenced cannot exhibit it. Deciding that is a tree walk rather than a regex, so | |
| the rule moved to an AST pass: | |
| | | Hits | Per 1000 lines | | |
| |---|---|---| | |
| | Regex over the declaration | 198 | 5.66 | | |
| | β¦exempting `required=True` | 142 | 4.11 | | |
| | AST: no `ondelete` **and** read through | 33 | 0.96 | | |
| and the finding names the read that makes it matter: | |
| ``` | |
| task_id declares no ondelete, so the ORM defaults it to 'set null' -- and | |
| task_id.filepath on line 37 reads through it, which yields False rather than | |
| raising once the parent is deleted | |
| ``` | |
| What that gives up is real and deliberate: a generated snippet that declares a | |
| `Many2one` and does nothing else with it is no longer flagged. The rule still asks for | |
| an explicit `ondelete` on every one. The **checker** is narrower than the rule and | |
| fires only where the code is actually exposed β the same gap `required=True` already | |
| had, written down rather than discovered. | |
| Total across the corpus: **8.5 β 5.3 hits per 1000 lines**. | |
| One of the hits was a real defect in OCA rather than in the corpus: `auditlog`'s | |
| stored `display_name` reads `user_id.name` while declaring only `user_id`, so the | |
| stored value goes stale when a user is renamed. | |
| **A second corpus, six times the size.** OpenSPP 2 β 1,353 files, 229,600 lines | |
| of Odoo 19 β gives **2.1 hits per 1000 lines**, against 3.9 on the OCA sample. | |
| A larger, newer codebase and a lower rate, which is the direction a precision | |
| number should move when the sample stops being small. | |
| **And something the OCA sample could not show.** 22 of the 128 | |
| `sudo-bypasses-record-rules` hits land on lines carrying | |
| `# nosemgrep: odoo-sudo-without-context` β a different static analyser looked for | |
| the same construct on the same line, and somebody read the finding and decided to | |
| keep the code. Two things follow, and they pull in opposite directions: | |
| - The rule is not idiosyncratic. An independent tool, written by people who have | |
| never seen this corpus, is looking for the same thing. | |
| - Nexa had no way to say "decided, keep it", so those 22 were questions this | |
| project had already answered and every run asked again. | |
| `layers.scan_source` reads those markers now β `noqa`, `nosemgrep`, | |
| `pylint: disable`, `type: ignore`, `eslint-disable`, `@ts-ignore` β and marks the | |
| hit. `fieldtest` counts those separately under ALREADY DECIDED and leaves them out | |
| of the per-kloc rate; `check` lists them under their own heading and does not fail | |
| a build over them; and neither drops one. The rate moved 2.2 β 2.1 because 22 hits | |
| left it, and all 22 are still on screen with their rule and line. | |
| **There is deliberately no `# nexa: ignore`.** Recognising another tool's marker | |
| means somebody read a finding from an independent analyser and decided; a marker | |
| of our own would mean somebody wanted this tool quiet, and it would be the first | |
| mechanism here capable of hiding a real finding. The whole design is an argument | |
| against having one. | |
| Hits on suppressed lines also do not enter the grounding for `check`. They are | |
| reported; grounding an answer in a settled question spends one of six slots on it. | |
| ### 3.19 Every rule carries a retrieval probe | |
| `nexa report rules` used to print *"never retrieved (7 of 32) β dead weight, or worded | |
| so the embedding never matches"*. It was reading telemetry: 15 recorded queries, twelve | |
| of them Odoo and one TypeScript, against a `TOP_K` of 6. Ten TypeScript rules and one | |
| TypeScript query means at least four TypeScript rules **could not** have been | |
| retrieved however they were written. Five of the seven names were arithmetic about the | |
| sample, presented as a finding about the corpus. | |
| Telemetry can say what *was* retrieved. Whether a rule *can* be is a different | |
| question, so each rule now carries a `probe`: one question phrased the way somebody | |
| meets that problem, deliberately not a paraphrase of the rule (a probe built from the | |
| rule's own wording would match whatever the rule said). `nexa eval --retrieval` asks | |
| each one and reports the rank. The build fails if a rule has no probe. | |
| All 32 are retrievable at `TOP_K` 6 and at top 3. Two were not and were reworded β | |
| the same failure `prefer-sql-constraint` had, which is what the probes exist to catch | |
| before somebody notices by accident. | |
| The report states its own limits: pinned rules are prepended regardless of score so | |
| ranking against them measures the pin, and a stack with fewer rules than `TOP_K` + 1 | |
| cannot fail the test at all. | |
| ### 3.20 One answer to where Ollama is, and one to every way it can fail | |
| Three files each hardcoded `http://127.0.0.1:11434`. `bin/endpoint.py` resolves it | |
| once from `OLLAMA_HOST`, per call rather than at import, so a test can point the whole | |
| system at a socket that is closed, slow or serving nonsense after the module has | |
| loaded. | |
| That mattered because the commonest failure this tool has printed a stack trace: | |
| ``` | |
| urllib.error.URLError: <urlopen error [WinError 10061] No connection could be | |
| made because the target machine actively refused it> | |
| ``` | |
| `endpoint.explain()` converts each failure once, at the single point every caller | |
| reaches the server through, and each conversion carries the next step: which address | |
| was tried and whether that came from the environment, *which* model a 404 is about, | |
| Ollama's own words from a 500, that a cold 30B load can exceed the timeout. Every | |
| entry point catches it and exits 1 β called correctly, could not finish, which is not | |
| the same as called wrong. | |
| ### 3.21 Appending telemetry needs a lock, on Windows | |
| `telemetry.jsonl` is opened in append mode by every process that emits an event. Four | |
| processes, forty events each: | |
| | Strategy | Records surviving | | |
| |---|---| | |
| | `io.open(path, "a")` + write | 154β159 of 160 | | |
| | `os.open(O_APPEND)` + `os.write` | 151 of 160 | | |
| | lock, seek, write, unlock | 160 of 160 | | |
| No torn lines in any of them, and no process reported an error β the lost records were | |
| silently overwritten. Windows implements `O_APPEND` as a seek to end followed by a | |
| write: two operations, so two processes resolve the same offset and one clobbers the | |
| other. POSIX `O_APPEND` is atomic for a write this small, so the lock costs a syscall | |
| there and is the whole point here. | |
| `emit()` takes an exclusive advisory lock on one byte before appending β | |
| `msvcrt.locking` on Windows, `fcntl.flock` elsewhere, an unlocked append as the | |
| fallback where neither exists, because a line that might be lost still beats no | |
| telemetry. It still never raises. | |
| This matters because `nexa report` counts those lines. A tool arguing that its numbers | |
| can be trusted cannot quietly drop 4% of them whenever two terminals are open. | |
| ### 3.22 The corpus hash identifies the corpus, not the machine | |
| `evidence.corpus_sha256()` digests the rule files byte for byte, and that hash is in | |
| every bundle to say which corpus a verdict was reached against. `build_corpus.py` | |
| wrote its JSON in text mode, so on Windows every newline became CRLF β and the same 32 | |
| rules hashed differently on Windows and Linux. No bundle from one machine could be | |
| matched against a bundle from another. | |
| Fixed at both ends: the generator writes LF explicitly, and the hash normalises CRLF | |
| to LF before digesting, so a checkout that arrives with CRLF still identifies itself | |
| correctly. Verified across both platforms: | |
| ``` | |
| Windows b4702925cd802d1abc9556faad464ce5987b7baa5d767e74cbd3b50ab269c861 | |
| Linux b4702925cd802d1abc9556faad464ce5987b7baa5d767e74cbd3b50ab269c861 | |
| ``` | |
| ### 3.23 A fourth stack, and what `nexa check` does with a .py file | |
| `nexa check` used to map every `.py` to Odoo. Then, for a while, it refused a `.py` | |
| with no `odoo` import β honest, because reviewing a plain script against fifteen ORM | |
| rules produces fifteen findings about a framework it does not use, and not useful, | |
| because refusing is not an answer. | |
| The Python stack is the answer. Eight rules, the same subject as the rest of the | |
| corpus: `assert` disappearing under `-O`, the mutable default argument, the bare | |
| `except` that swallows `KeyboardInterrupt`, `eval`/`pickle` on untrusted input, | |
| `shell=True`, `os.path.join` discarding its base on an absolute component, binary | |
| floats for money, and naive-versus-aware datetimes. Every one is documented upstream | |
| and carries a pattern. | |
| `check` now decides from the file: an `odoo` import or a `__manifest__.py` above it | |
| means the Odoo rules, anything else means plain Python. `--stack` overrides either | |
| way. | |
| Measured before shipping, on Nexa's own `bin/` β 5,000 lines of stdlib-only Python: | |
| **0 hits**. On the corpus generator, 11, every one of them the rule text quoting | |
| `shell=True` and `datetime.now()` inside a string literal, which is what the real | |
| checker does too (Β§3.13 strips comments, not strings). All 8 are retrievable by | |
| their own probe at top 3. | |
| Two of the eight were narrowed by that field test before they ever ran on anybody | |
| else's code: `os.path.join`'s untrusted-input tokens were unanchored, so `form` | |
| matched inside `.format(`; and money-versus-float fired on any `float()` in a file | |
| with no `Decimal`, which caught a Wilson score interval. | |
| The fourth stack left `context.py` behind. That module answers the same question | |
| β which stack is this file β from the extension alone, and its docstring claimed | |
| to match `cmd_check`. It did not, in three ways. A plain `.py` was python to the | |
| router and odoo here, so the only thing the context layer ever said about a | |
| Python script was that its `__manifest__.py` was missing: a true sentence about a | |
| question that does not apply. `.mts` and `.cts` were TypeScript here and fell | |
| through to Python there, so a `.mts` file was reviewed against the Python corpus | |
| while this layer went looking for its tsconfig; `.cjs` the same. | |
| There is one extension table now, and the caller that read the file passes the | |
| routed stack back through `from_file(stack=...)` β the extension can say which | |
| family a file belongs to, and only the content can say whether a `.py` is an | |
| addon. `nexa eval --check` is what surfaced it (3.26): routing is one of the four | |
| numbers it prints, and two answers to the same question disagree visibly the | |
| moment something prints them side by side. | |
| ### 3.24 `nexa build` could not register a model on exFAT | |
| Ollama resolves an unqualified `FROM` against the Modelfile's own directory before | |
| trying the registry. That candidate path contains a colon β | |
| `...\modelfiles\qwen2.5-coder:7b` β and on exFAT Windows returns | |
| `ERROR_INVALID_NAME` rather than "not found". Ollama treats that as fatal and never | |
| reaches the registry: | |
| ``` | |
| Error: CreateFile D:\nexa\modelfiles\qwen2.5-coder:7b: The filename, | |
| directory name, or volume label syntax is incorrect | |
| ``` | |
| So the last step of `nexa build` β the one that registers the models β could not work | |
| at all when the repository lived on an exFAT drive, which an external SSD holding a | |
| dev tree very often is. The generated Modelfiles now carry | |
| `FROM registry.ollama.ai/library/...`, which is resolved before the path attempt. | |
| Worth recording how this was nearly mis-diagnosed. The first comparison was an LF | |
| Modelfile on `D:` against a CRLF copy on `C:`, which failed and succeeded | |
| respectively, and the obvious conclusion was that Ollama wanted CRLF. It was wrong: | |
| two variables had changed. Moving the same bytes around one at a time showed the | |
| drive was the variable and line endings did not matter at all. A CRLF commit went in | |
| on the strength of the two-variable test and came straight back out. | |
| ### 3.25 Recall, and the number that was nearly flattering | |
| Β§3.18 measures how often a checker is wrong when it fires. Nothing measured how | |
| often it fails to fire, and the distinction matters more here than the precision | |
| work did: crosscheck, adversarial and fieldtest would all be perfect on an empty | |
| corpus. | |
| `nexa recall <repo>` reads git history. For every commit that looks like a fix, | |
| it runs the checkers over each changed file as it stood before the commit and | |
| again after, and counts a **catch** when a finding was present before, is absent | |
| after, and sat on a line the commit actually changed. | |
| Against OCA/server-tools and OCA/account-financial-tools, 385 fix commits, 56 of | |
| them touching a construct the corpus covers: | |
| | | | | |
| |---|---| | |
| | Caught | 1 | | |
| | Missed | 55 | | |
| | Rules that caught anything | `check-company-on-relations`, once | | |
| **The third condition is the measurement.** Without "on a line the fix changed", | |
| the first run of this tool reported 12 catches, eleven of them from | |
| `ondelete-is-a-decision`. The commit subjects were *"fix image url in locales | |
| with thousand separator"*, *"poor performance updating assets massively"*, *"fix | |
| JS crash in analytic distribution"*. That checker fires on a `Many2one` with no | |
| `ondelete` that is read through, so any refactor moving the read makes the | |
| finding vanish. Eleven refactors, presented as the corpus catching bugs. | |
| The tool still reports how many commits fall in that gap, precisely so the | |
| looser number cannot quietly come back. | |
| **What follows from it.** The corpus is precise and narrow: right about what it | |
| describes, and describing very little of what goes wrong. Several of the 55 | |
| misses are inside its own stated territory -- a `sudo()` removed during import, | |
| an x2many leaking across companies, a stored compute not recomputing -- which | |
| means the gap is not only "rules that do not exist" but "rules that exist and | |
| did not reach". | |
| **What does not follow.** The denominator is generous: many of those 56 commits | |
| touched a covered construct while fixing something unrelated, so 2% understates | |
| recall against validation bugs specifically. Two repositories in one ecosystem | |
| is a narrow sample. This is a floor, and the first one the project has had. | |
| ### 3.26 `nexa check` is measured the way `nexa ask` is | |
| Everything above about verdicts, stability and escalation was measured on `nexa | |
| ask`. `nexa check` β the command somebody actually points at their own file β | |
| inherited none of it, and was assumed to inherit all of it because it ends in | |
| the same call. | |
| It does not. That call is the last step; the command is the four decisions in | |
| front of it. Which corpus the file is routed to, what the tree around it says, | |
| how much of the file fits in 8192 tokens, and what question gets built from the | |
| rest. Those lived inside `cmd_check`, next to the printing, where only a person | |
| running the command could reach them. They are now `bin/subject.py`, so | |
| `nexa eval --check` measures the decisions the command makes instead of a second | |
| copy of them that drifts away quietly. | |
| **Five files, five runs each, 25 reviews.** `subjects/` is committed β one file | |
| per route `check` can take, one of them too long to fit. This is the state | |
| `check` was in when it was first measured; 3.28 is what the measurement led to | |
| and carries the current numbers. | |
| | | | | |
| |---|---| | |
| | PASS | 5 of 25, 95% CI 9β39% | | |
| | WARN | 15 of 25, 95% CI 41β77% | | |
| | REJECT | 5 of 25, 95% CI 9β39% | | |
| | Same verdict every run | 5 of 5 files | | |
| | Files whose answer named nothing they declare | 2 of 5 | | |
| | Files where no layer found anything, five runs of five | 1 of 5 | | |
| | Seconds per review, median | 4.4 | | |
| **Every PASS is one file, and it is the worst one in the set.** `fsm_visit.py`: | |
| 7,929 characters, 24% never sent, five answers of 435β598 characters each naming | |
| 1 of the file's 19 symbols, and not one layer found anything on any run. A PASS | |
| under the stated policy, and the plainest number here for how little the checks | |
| see on a real file. | |
| That file used to WARN, on the module targeting Odoo 17.0 against a corpus | |
| checked against 18.0 β an environment fact that fired regardless of the answer, | |
| since demoted to a gap (3.27). The earlier run's 0-of-25 read as strictness and | |
| was an accident. | |
| **Stability is now its own number.** 5 of 5 files reach the same verdict every | |
| run with no constant holding them there, though the route varies: `fsm_job.py` | |
| reaches WARN through the semantic layer on four runs and through structural and | |
| syntactic on the fifth, where the model produced a 1,942-character answer that | |
| did not hold together. Same verdict, different reason, and none of them about | |
| the file. | |
| **The checkers never look at the file.** They run over the answer. On the Python | |
| and TypeScript subjects the model reproduced the file, so the file's own defects | |
| came back as findings β `mutable-default-argument`, | |
| `path-join-does-not-contain-a-path`, `unknown-not-any-at-boundaries`. On the two | |
| Odoo subjects it wrote a short generic snippet instead, 296 to 605 characters, | |
| naming 0 of the 7 and 1 of the 19 names those files declare; five runs of each | |
| produced no finding about the code at all. | |
| `nexa fieldtest` β the same corpus, the same checkers, no model anywhere β fires | |
| nine times on those two files: a stored compute whose `@api.depends` is | |
| incomplete (four times), an `@api.constrains` reading two fields it does not | |
| declare, an `onchange` used as a guard, a float compared with `==`. `nexa check` | |
| reported none of them. Two of the nine sit below the 6,000-character cut, so one | |
| run in five never saw a quarter of the file it was reviewing. | |
| **What follows.** On real code the deterministic half carries the weight and the | |
| generative half is decoration. The limitation note below called that an | |
| observation; this is the measurement. Feeding the first 6,000 characters of a | |
| file to a 7B and checking what comes back is the wrong shape for a review β the | |
| checkers should be pointed at the file, and the model asked about what they | |
| found. | |
| **What does not follow.** Five files, written for the measurement rather than | |
| sampled from anybody's production tree. `nexa eval --check <path>` takes a real | |
| one; `subjects/README.md` says what the committed set does and does not buy. | |
| ### 3.27 The context floor was a finding, and is now a gap | |
| The context layer had two findings that did not depend on the answer: | |
| this module targets Odoo 17.0; the corpus was checked against 18.0, so | |
| version-specific behaviour in these rules is unverified here | |
| tsconfig has strict off, so the compile-time half of any type-based | |
| guarantee in this answer is not actually enforced | |
| Both are true, both are worth knowing, and both were in the wrong slot. A | |
| finding says something in THIS ANSWER is wrong or unproven. These say the corpus | |
| was verified against a different series, and that this project has not finished | |
| turning strict on -- facts about the corpus and the tree, which hold whatever | |
| the answer says. | |
| As findings they were a floor. Any Odoo module not on the newest series, and any | |
| project mid-migration, drew a WARN on every run forever, so PASS was unreachable | |
| for most real users and the WARN stopped carrying information. That is the same | |
| argument that keeps `check` silent about a file that fits: a warning on every | |
| run trains people to skip it. | |
| The deciding detail at the time: **0 of the 15 Odoo rules carried an | |
| `odoo_versions` field.** `_version_applies` -- the check that produces a real | |
| finding, naming a retrieved rule that genuinely does not hold on this series -- | |
| had therefore never fired and could not. The blanket warning was standing in for | |
| per-rule data that did not exist, and spending a verdict to do it. | |
| All 15 carry one now (3.29), so the layer answers the version question per rule | |
| instead of once per module, and a module on a supported series reaches a | |
| contextual PASS on a check that actually ran. | |
| Both are gaps now, printed under "Not checked", and the layer reports UNKNOWN | |
| when they were all it had. Contextual confidence reads `none`, which is the same | |
| claim the WARN was making without burning the verdict. The per-rule version | |
| check keeps its finding. | |
| **What it did to 3.26, measured rather than projected.** The eval was re-run on | |
| the same five files after the demotion. Five of the twenty WARNs became PASS, | |
| exactly as the arithmetic on the recorded rows had said, and all five are | |
| `fsm_visit.py`: the file that lost 24% of itself to truncation and came back | |
| with a 435-character answer naming 1 of its 19 symbols. Five runs, and after the | |
| demotion no layer finds anything at all on any of them. | |
| That file now reads PASS. It was never being caught for being unreviewed; it was | |
| being caught for its manifest, and a right verdict for an unrelated reason is an | |
| accident rather than a safety property. Removing the accident is what makes the | |
| gap visible: **truncation is not evidence-bearing.** A review of the first 6,000 | |
| characters of a 7,929-character file should not reach PASS without the verdict | |
| saying so, and today the only thing that says so is a line of prose above the | |
| answer. `decide()` already has the mechanism -- an essential layer reporting | |
| UNKNOWN carries INSUFFICIENT EVIDENCE -- and truncation is not wired to it. | |
| Both of those are done now: 3.28 wires coverage into the verdict and 3.29 fills | |
| in the version data. The floor is gone and so is what it was masking. | |
| ### 3.28 `nexa check` reads the file, instead of only asking about it | |
| 3.26 measured `check` and found the shape of it wrong in two ways that had | |
| nothing to do with 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 single answer to *what do the checkers say about | |
| this source file*, and both `check` and `fieldtest` route through it -- the | |
| latter since 3.33, which is when that sentence became true rather than intended. | |
| `check` | |
| runs it before generation and prints the findings with their line numbers. They | |
| are established without a model and they do not move between runs. | |
| The model is then shown those findings and asked to explain and repair them, and | |
| to look for what no pattern can decide. It is a better question than the one it | |
| replaced: *review this file* invited a rewrite, and got one. | |
| **The excerpt was the first 6,000 characters.** A character count is not a unit | |
| anybody thinks in, and it cannot be chosen well β the old rule cut | |
| `fsm_visit.py` 1,900 characters above `_check_no_overlap`, which carries three | |
| of that file's six findings. | |
| The file is now split into symbols β functions, classes, methods, each with its | |
| decorators, because `@api.constrains('engineer_id')` is half of what the method | |
| below it means. Units are selected whole, **the ones a checker fired on first**, | |
| and what is left out is named in the report, marked in place in the excerpt, and | |
| declared to the model. A unit larger than the whole budget has its head taken and | |
| its name says how much of it that is, so the degenerate case is never worse than | |
| the rule it replaced. | |
| **Coverage is evidence, and its absence is the lack of it.** `decide()` takes the | |
| context now, and a review that did not cover the file carries INSUFFICIENT | |
| EVIDENCE: | |
| WARN (INSUFFICIENT EVIDENCE: 9 of 20 symbols were not reviewed) | |
| 3.27 exposed the need for this: with the version floor gone, the one file nothing | |
| could see returned a bare PASS. The prose above the answer said it was an | |
| excerpt; the verdict is what gets read. | |
| **Re-measured on the same five files, five runs each.** The `after` column is | |
| the committed state, with the checkers' findings reaching the grounding as well | |
| as the report (3.31). | |
| | | before | after | re-run | | |
| |---|---|---|---| | |
| | Findings reported about the files | 0 | **23** | **23** | | |
| | Files with a finding reported | 0 of 5 | **5 of 5** | **5 of 5** | | |
| | The same files, through `fieldtest` with no model | 23 | 23 | 23 | | |
| | PASS / WARN / REJECT | 5 / 15 / 5 | 0 / 18 / 7 | 0 / 12 / 13 | | |
| | Same verdict every run | 5 of 5 | 4 of 5 | 3 of 5 | | |
| | Files reaching a contextual verdict | 0 of 5 | 2 of 5 (3.29) | 2 of 5 | | |
| | Symbols of their own the answers name | 34 | **90** | 82 | | |
| | Rules the model cited beyond the checkers | β | **1**, in 25 reviews | **3**, in 25 reviews | | |
| | The truncated file | PASS x5, nothing found | WARN/REJECT, INSUFFICIENT EVIDENCE | REJECT x5 | | |
| The `re-run` column is the same command against the corpus at 43 rules, and the | |
| first three rows are why it is worth having: every deterministic number is | |
| identical and every model-dependent number moved. None of the three rules added | |
| in 3.34 and 3.35 fires on any of these files, so nothing in the corpus explains | |
| the difference. | |
| `partners.ts` settles what does. It moved from WARN five times out of five to | |
| WARN/REJECT, and it is TypeScript -- `ai-ts` was not rebuilt and its ten rules | |
| did not change. There is nothing left for that to be except the model at | |
| temperature 0.15, and five runs being a small sample of it. | |
| The PASS column going to zero is the point rather than a regression. Every one | |
| of the five files has findings in it, and the one that used to pass five times | |
| out of five was the one nothing could see. | |
| The 23 is the number to look at, and it is not a model improvement: it is the | |
| corpus finally being pointed at the thing it was written to check. `fieldtest` | |
| extracted exactly the same 23 from the same files before any of this, with no | |
| model installed, which is what made the gap measurable in the first place. | |
| `fsm_visit.py`'s last method is reviewed now rather than cut off, because three | |
| of its six findings sit in it and the excerpt is ranked on that. | |
| **What the generative half is worth, with a number that moves.** Handing the | |
| model the findings did make its answers more about the file: the symbols they | |
| name went from 34 to 90 across the set. What it *adds* is a different question, | |
| and the answer is small and unstable -- one rule cited beyond the checkers in 25 | |
| reviews on the first run, three on the second, and not the same rules either | |
| time. | |
| That correction matters more than the figure. "One in 25" was quoted here as | |
| though it were a property of the tool; it was one sample of a quantity that | |
| tripled on the next run of the identical command. The direction holds -- the | |
| deterministic half is what carries this tool on real code -- but the number | |
| attached to it is an interval nobody has measured, on a sample far too small to | |
| be worth measuring. The deterministic | |
| half carries this tool on real code and the design says so now instead of hoping | |
| otherwise. Where to spend the next hour follows from it: another rule in the | |
| corpus is worth more than another prompt, and 3.32 is what happened to the | |
| prompt that was tried. | |
| **And what follows for the command.** A handful in 25 is worth having and is not | |
| worth paying for every time. `nexa check --no-model` stops after the checkers: | |
| 0.26 seconds, no Ollama, and the same output every run -- against a path where | |
| three of five files gave different verdicts across runs of the identical command. | |
| The instability is the stronger half of the argument, and it only became visible | |
| by running the eval twice. A gate wants a number that does not move; this one | |
| moves, and the flag is how a gate stops depending on it. | |
| It is a second mode rather than a new default, because the one citation is real. | |
| What the flag changes is who decides: a person reading a file wants the part no | |
| pattern can decide, and a pipeline gate wants the part that is the same every | |
| time, and those had been the same command. | |
| It prints no verdict. There is nothing to have a verdict about -- no answer was | |
| generated, so nothing was checked except the file -- and a clean run says *no | |
| pattern fired* in those words rather than anything that could be read as a pass. | |
| That is the same refusal `decide` makes when a layer could not run, in the one | |
| place where the missing layer is all of them. | |
| One verdict moved between runs (`load_prices.py`, REJECT once in five), which is | |
| the honest cost of a non-deterministic component anywhere in the pipeline and | |
| the reason this eval exists. | |
| **And one thing the numbers exposed.** `fsm_job.py` reaches PASS with three | |
| checker findings printed above it -- because the verdict is about the answer and | |
| the findings are about the file, which are different claims about different | |
| things. Both are true and printing them together invited conflation, so `check` | |
| says which is which, and its exit code follows the file: a clean answer about a | |
| file with three critical findings in it still exits 1. | |
| ### 3.29 Every Odoo rule declares the series it holds for | |
| `odoo_versions` existed, `_version_applies` parsed four kinds of range, and | |
| `layer_context` was ready to report a retrieved rule that does not apply here. | |
| None of it had ever run, because no rule carried the field. | |
| The field's absence was not neutral. The layer read a missing `odoo_versions` as | |
| *assumed to hold on every series*, and said so under "Not checked" β which is a | |
| broader claim than any range anybody would actually write. Fifteen rules made | |
| that claim by saying nothing. | |
| All fifteen now declare `>=16.0`. Two things follow: | |
| - A module on 17.0 gets a **contextual PASS on a check that ran** β six retrieved | |
| rules, each declaring a range, each covering 17.0 β instead of a gap saying the | |
| question was unanswerable. That is the proper replacement for the floor 3.27 | |
| removed: not silence, but an answer. | |
| - A module on a series outside the range gets **one finding per rule, naming the | |
| rule**, rather than one blanket sentence about the module. That is the finding | |
| the mechanism was built for. | |
| **What `>=16.0` is, and what checks it.** It was an assertion about API | |
| stability rather than something anybody had verified, and there is no 16.0 | |
| checkout here to test against. The strongest evidence available is the | |
| documentation: if the API a rule is about is described in the 16.0 and 17.0 | |
| references as well as 18.0, the rule's subject exists in those series. | |
| `corpus/check_sources.py` does that, for every Odoo rule against every series it | |
| claims. Authoring-time, like the rest of the provenance work (3.7) β it is the | |
| one thing in this repository that touches the network, it never runs to answer a | |
| question, and nothing at query time depends on it. | |
| | | | | |
| |---|---| | |
| | Rule-series where the cited page exists and names the API | 42 | | |
| | β¦where the citation 404s but the ORM reference has it | 2 | | |
| | β¦where neither does | **1** | | |
| | Rules citing something other than the Odoo docs | 3 (9 rule-series) | | |
| The two `reference` results are the tutorials, which were reorganised between | |
| 16.0 and 17.0: the page moved, the API did not. The one genuine gap is | |
| `float-comparison-needs-float-utils` β `odoo.tools.float_utils.float_compare` is | |
| not described anywhere in the 16.0 documentation, on the ORM reference, the | |
| coding guidelines or the changelog. | |
| That rule carries `odoo_versions_basis`, a new optional field saying why its | |
| range holds anyway. The checker exits non-zero for any rule that has neither | |
| documentation support nor a stated basis, so no version claim in this corpus | |
| rests on nothing. | |
| A documentation page is not a test run, and the tool says so in its own output. | |
| What it catches is the case that matters most: a rule about an API that was not | |
| there yet, or was described differently. | |
| **And the blind spot it has.** A function Odoo shipped and never documented reads | |
| as missing, which is exactly the case above: the basis field was a hand-written | |
| sentence covering a gap in the evidence rather than a gap in Odoo. | |
| `check_sources.py --source` closes it by reading odoo/odoo instead. For each rule | |
| it asks whether the API is DEFINED in that series' module β `def float_compare(` | |
| in `odoo/tools/float_utils.py`, not the word anywhere on a page β fetching the | |
| handful of files it needs rather than cloning three branches to answer a question | |
| about eighteen symbols. | |
| | | | | |
| |---|---| | |
| | Rule-series defined in source across 16.0, 17.0 and 18.0 | **54 of 54** | | |
| No Odoo rule's range now rests on an assertion. `float_compare`'s basis states a | |
| measurement instead of a belief: the signature is byte-identical in all three | |
| series, `float_compare(value1, value2, precision_digits=None, | |
| precision_rounding=None)`. | |
| Two things only the source could settle. `_check_recursion` is in every series and | |
| `_has_cycle` only in 18.0; the rule already said so and said the result is | |
| inverted, and 18.0's own deprecation warning β "one must use not _has_cycle() | |
| instead" β confirms both halves. And `SOURCE_SYMBOLS` is completeness-checked | |
| exactly as `SYMBOLS` is, because a rule with no entry is a rule the check silently | |
| skipped. | |
| A definition is not a test run either. It says the API existed and was spelled the | |
| same way, not that it behaved identically. | |
| ### 3.30 The missed list becomes a worklist | |
| 3.25 measured recall against OCA and found 1 catch in 56 in-scope fixes, and | |
| said the missed list was the more useful output: every entry is a bug somebody | |
| found, fixed and pushed, which makes it a candidate rule with an incident behind | |
| it rather than another one written from an opinion. | |
| A flat list of 142 subjects is not a worklist, though, and that is what it was. | |
| `nexa recall --harvest` clusters the misses by the construct each diff touched, | |
| ranks the constructs by how many misses they carry, and marks the ones where the | |
| corpus already catches something. A construct with forty misses against it and no | |
| catches is where a rule is missing; the same forty scattered across everything is | |
| noise, and only the clustering tells them apart. | |
| **Measured against OpenSPP 2 (Odoo 19), 1,391 commits of history.** A second | |
| ecosystem, and one built on a newer series than the OCA repositories: | |
| | | | | |
| |---|---| | |
| | Fix commits examined | 368 | | |
| | β¦touching a construct the corpus covers | 147 | | |
| | Caught | 6 | | |
| | Recall against in-scope fixes | 3% | | |
| | Rules that caught anything | `sudo-bypasses-record-rules` (5), `stored-compute-needs-complete-depends` (1) | | |
| Two repositories, two ecosystems, 2β3% both times. The floor is a floor. | |
| The clustering is where the work is: | |
| 65x .sudo( (5 caught) | |
| 37x ValidationError (1 caught) | |
| 25x Many2one (1 caught) | |
| 21x api.depends (nothing caught here) | |
| 19x noupdate (1 caught) | |
| 15x api.constrains (nothing caught here) | |
| 13x ondelete (nothing caught here) | |
| Twenty-one fixes touched `@api.depends` and the corpus caught none of them, while | |
| carrying a rule about exactly that construct. Fifteen touched `@api.constrains` | |
| against three rules about it. Those are not gaps in coverage -- the rules exist -- | |
| they are gaps between what the rules describe and what actually goes wrong, which | |
| is a more useful thing to know and a harder one to guess. | |
| **The word signal, and why it is filtered.** Recurring words in the missed | |
| subjects are the cheapest topic signal available and the first run of it returned | |
| `spp_api_v`, `semgrep`, `comments`, `review` -- the vocabulary of working on that | |
| particular repository. Conventional-commit scopes are read out of the subjects and | |
| excluded, along with a list of process words, which leaves `resolve` (12), | |
| `sudo` (8), `missing` (5), `harden` (4). Still crude, and labelled as crude. | |
| Read the diffs, not the subjects. A subject says what somebody meant to fix; the | |
| diff says what was wrong, and that is what a rule has to describe. | |
| ### 3.31 The embedder's cut, measured instead of assumed | |
| `check` builds a question from a file and hands the whole thing to retrieval, | |
| which embeds it with nomic-embed-text. That model has a context limit and Ollama | |
| does not complain when a query exceeds it; it truncates and returns a vector. | |
| The claim that followed from that -- "the back of every long file is invisible to | |
| retrieval" -- was a deduction, and this document had it as one for about an hour. | |
| It is testable in seconds: append a distinctive marker and see whether the vector | |
| moves. | |
| prompt (7,931 chars) vs prompt + marker -> identical vectors | |
| bisecting for where it stops mattering: | |
| text appended before ~7,311 chars -> the vector moves | |
| text appended after ~7,435 chars -> it does not | |
| So the cut is real and it lands around 7,400 characters, which on the longest | |
| subject is the last **6%** of the prompt -- not the back half, and not the back of | |
| the file. The deduction was right in direction and wrong in size, which is the | |
| usual shape of a deduction. | |
| **What follows for the compact query.** `subject.retrieval_query` describes a file | |
| by what it declares, what it imports and what the checkers already found in it, | |
| instead of by 6,000 characters of source. `nexa eval --compare-retrieval` scores | |
| both against the rules the checkers proved apply -- ground truth that costs | |
| nothing and involves no opinion: | |
| | | | | |
| |---|---| | |
| | whole prompt | 14 of 19 fired rules in the top 6 | | |
| | compact query | 15 of 19 | | |
| One rule, on one file, out of nineteen. That is a coin toss with a table around | |
| it, and the defect it was meant to fix costs 6% of one prompt. **It is not wired | |
| in.** The function stays as the thing the comparison compares, and it gets | |
| adopted when a number says to rather than because it sounds tidier. | |
| **What is wired in, and did not need measuring.** `retrieve()` takes `require`, | |
| and `check` passes the rules its own checkers fired on. A rule proved to apply at | |
| line 75 of this file does not need a cosine to earn a place in the grounding. | |
| That is deduction of a kind that cannot come out wrong: the evidence is already | |
| in hand, and the alternative is asking similarity to guess at something already | |
| established. | |
| ### 3.32 The rewrite is load-bearing, which nobody expected | |
| 3.28 changed how `check` builds a question and left the question itself alone, | |
| noting that "review this file" invites a rewrite and reliably gets one -- the | |
| model reproducing the file under a new name instead of reviewing it. That read | |
| as an obvious defect and the obvious fix was to ask for less. | |
| **The obvious fix, measured.** A file with findings in it was asked to "show the | |
| corrected code for the findings above -- only the lines you would change" instead. | |
| Same five files, five runs each, the only difference the question: | |
| | | review this file | show only what changes | | |
| |---|---|---| | |
| | Names of the file's own symbols mentioned | 90 | **65** | | |
| | Rules cited beyond what the checkers found | 1 | **0** | | |
| | Median answer length | 2,166 chars | 1,516 | | |
| | WARN / REJECT | 18 / 7 | **9 / 16** | | |
| | Layers that found something | semantic, structural, domain | + **knowledge 5x, syntactic 3x** | | |
| Worse on every one, and the last row says why. Isolated changed lines are not | |
| parseable code, so the syntactic layer fired three times and was right to; the | |
| shorter answers also reached for rule ids they had not been shown, which the | |
| knowledge layer caught five times. The rephrasing fought the output contract -- | |
| a fenced block that parses -- rather than the model. | |
| **What it exposes.** The rewrite is not a bug in the prompt, it is the mechanism. | |
| The checkers run over the ANSWER (3.28 moved a second pass onto the file, and | |
| kept this one). An answer that reproduces the file is an answer those checkers | |
| can find the file's own defects in, which is why `load_prices.py` -- reproduced | |
| in full, 5 of 5 symbols named -- yields `mutable-default-argument`, | |
| `path-join-does-not-contain-a-path` and `decimal-for-money-not-float` every run. | |
| Asking for a diff removes the reproduction, and with it the layer doing the work. | |
| So the question stays as it is, with a comment on it saying why, and the | |
| low symbol counts on the Odoo files are a fact about the 7B rather than a prompt | |
| waiting to be fixed. | |
| **Second time this session.** The compact retrieval query (3.31) also sounded | |
| obviously better and measured as a coin toss. Both were an hour's work, both were | |
| reverted by a number, and both would have shipped on how sensible they read. That | |
| is the argument for `nexa eval --check` existing at all: not to prove the tool | |
| works, but to stop plausible changes getting in on plausibility. | |
| ### 3.33 Somebody else already decided, and a claim that was not true | |
| Two things, and the second is a correction. | |
| **Already decided.** 3.18 records that 22 of the 128 `sudo-bypasses-record-rules` | |
| hits on OpenSPP sit on lines carrying `# nosemgrep: odoo-sudo-without-context`. | |
| An independent analyser looked for the same construct on the same line and a | |
| human read that finding and kept the code. Re-raising it as news on every run is | |
| how a report gets skimmed, and a skimmed report is a report that misses the one | |
| thing that mattered. | |
| `layers.scan_source` reads those markers now -- `noqa`, `nosemgrep`, | |
| `pylint: disable`, `type: ignore`, `eslint-disable`, `@ts-ignore` -- and sets a | |
| flag on the hit. What each consumer does with it: | |
| | | | | |
| |---|---| | |
| | `fieldtest` | counts them under ALREADY DECIDED, out of the per-kloc rate | | |
| | `check` | lists them under their own heading, and does not fail a build on them | | |
| | `check`'s grounding | leaves them out -- a settled question should not spend one of six slots | | |
| | all three | never drop one: rule and line stay on screen | | |
| The OpenSPP rate moved 2.2 to 2.1 per 1000 lines, which is what 22 hits leaving | |
| a 229,600-line denominator looks like. | |
| **There is deliberately no `# nexa: ignore`.** Recognising another tool's marker | |
| means an independent analyser raised something and a person decided. A marker of | |
| our own would mean somebody wanted THIS tool quiet, and it would be the first | |
| mechanism in this repository capable of hiding a real finding. Everything else | |
| here -- UNKNOWN as a verdict, declared gaps, INSUFFICIENT EVIDENCE, "PASS means | |
| the checks that ran found nothing" -- is an argument against having one. | |
| **The correction.** 3.28 said `layers.scan_source` was "the single answer to what | |
| the checkers say about this source file, and both `check` and `fieldtest` route | |
| through it". The first half was true. The second was not: `fieldtest` kept its own | |
| copy of the loop -- compiled patterns, its own `run_check` call, its own line | |
| arithmetic -- and nothing noticed, because both copies agreed on the samples | |
| anybody ran. | |
| They are one call now, which is what makes the suppression flag appear in both at | |
| once instead of in whichever was remembered. That is the same argument 3.2 makes | |
| about the corpus and the Modelfiles, and it took writing a second feature to | |
| notice the drift had already happened. | |
| ### 3.34 Somebody else's ruleset, which is the only outside opinion available | |
| Every measurement above tests whether what we wrote behaves. `crosscheck` fires the | |
| checkers at the corpus's own correct samples, `adversarial` rewrites those samples, | |
| `fieldtest` fires them at a stranger's tree, `recall` at a stranger's history. All | |
| four ask the same question from different angles. | |
| None of them can ask whether the corpus is the right set of rules to have written, | |
| and that question cannot be answered from inside it: a rule nobody thought of is | |
| invisible to every check that reads the rules. Coverage of the rules is not coverage | |
| of the subject, and nothing here could tell the difference. | |
| OpenSPP runs semgrep against a hand-written Odoo ruleset of its own, 44 rules in | |
| `.semgrep/odoo-security.yml`. Somebody else decided what was worth checking, on the | |
| same platform, without reading ours. `nexa crosswalk` compares the two. | |
| **Semgrep is never run.** It is not installed here and an offline tool will not grow | |
| a package index to answer one question. Instead each of their patterns is turned into | |
| the smallest snippet it would match -- `$CR.execute($QUERY % ...)` becomes | |
| `_cr.execute(_query % _h1)` -- and OUR checkers are run over that. Every pairing then | |
| rests on a checker firing rather than on two rule descriptions sounding alike, which | |
| is the failure mode any hand-written mapping between two rulesets would have. | |
| | | | first run | now | | |
| |---|---|---|---| | |
| | Their rules read | 44 | | | | |
| | Their patterns turned into a probe | 146 of 146 | | | | |
| | Both corpora fire on the same construct | | **6** | **10** | | |
| | Theirs fires, nothing here does | | 38 | 34 | | |
| | Ours no pattern of theirs could match | 7 of 23 comparable | | | | |
| The first six were `eval`, `exec`, `pickle`, `os.popen`, `shell=True` and domain | |
| injection through `eval` -- every one of them reached by the two Python rules about | |
| executing input, and none by an Odoo rule. The other four are the rules this run | |
| produced, which is the difference between a measurement and a worklist: the number | |
| moved because the gap it named got closed, not because anything was re-scored. | |
| On everything else the two corpora barely intersect, and the honest reading of that | |
| is not that either is wrong: theirs is about security and this one is about ORM | |
| correctness. A gap on a subject a corpus deliberately does not cover looks exactly | |
| like one it forgot, and the report says so rather than scoring it. | |
| **The evidence that is not about rule text.** OpenSPP's source carries 687 | |
| `# nosemgrep:` markers. Each one is a finding that fired AND that a person read and | |
| decided to keep, which makes it the only record available here of what the other | |
| analyser does on real code. It is partial in a known direction: it records what was | |
| dismissed and says nothing about what was fixed. | |
| | | | | |
| |---|---| | |
| | Lines their analyser fired on | 631 (56 more markers name no rule) | | |
| | β¦where a checker here also fires | **20** | | |
| Twenty of 631. `odoo-sudo-without-context` fires on every `.sudo()`; | |
| `odoo.sudo-bypasses-record-rules` fires on `.sudo().search(` and clears on a | |
| `company_id` filter. Both decisions are defensible and they are different rules about | |
| the same word, which is what most of the disagreement turns out to be. | |
| **Two rules came out of it.** `odoo-commit-in-loop` and the SQL-injection family were | |
| subjects with a real correctness failure behind them and nothing here that fired. | |
| Both are now rules, and both are AST passes rather than patterns for the same reason | |
| from opposite directions: a commit is the same string wherever it sits, and a | |
| parameterised query carries the same percent sign as an interpolated one. A regex | |
| cannot see either difference. | |
| The commit rule found exactly one hit in 229,600 lines of OpenSPP, inside a loop | |
| their analyser had flagged too: `# nosemgrep: odoo-commit-in-loop β batch consent | |
| expiry cron`. Two analysers, one construct, one file, and a person who had already | |
| decided. That is the strongest corroboration anything in this repository has. | |
| They anchor differently, which the fieldtest reports rather than smooths over: semgrep | |
| matches the whole loop and its marker sits on the `for`, this checker reports the | |
| `commit()` ten lines inside it, and `scan_source` reads a marker only on the finding's | |
| own line. So the hit counts as live here while a person has plainly already decided | |
| about it. Widening the window would fix the count and build the thing 3.33 refused -- | |
| a marker silencing findings it was never written about. | |
| ### 3.35 Reading the harvest, and a column it was missing | |
| 3.30 turned the missed list into a worklist clustered by construct, and the ranking | |
| said `21x api.depends (nothing caught here)` against a rule about exactly that. The | |
| obvious reading is a missing rule, and the obvious reading was wrong. | |
| Reading the diffs -- which 3.30 says to do and which nobody had -- of the 23 fix | |
| commits whose diff touched `@api.depends`, most merely ADD a compute. The decorator is | |
| in the diff because a new one was written, not because a depends bug was fixed, and | |
| the scope-token denominator counts them anyway. Of the rest, the interesting case was | |
| `spp_area_hdx/models/hdx_cod_source.py`: the checker fired correctly on the file | |
| before the fix, and the fix went to a different line. Recall scored that a miss, and | |
| the worklist then said "nothing caught here" about a rule that was firing. | |
| **The column.** `measure` now records `spoke` -- rules with a finding in the file that | |
| is not a catch -- and `--harvest` splits every cluster on it: | |
| | | missed | caught | spoke | silent | | |
| |---|---|---|---|---| | |
| | `.sudo(` | 70 | 6 | 47 | 23 | | |
| | `ValidationError` | 39 | 1 | 29 | 10 | | |
| | `Many2one` | 26 | 1 | 14 | 12 | | |
| | `api.depends` | 23 | 0 | 16 | **7** | | |
| | `api.constrains` | 16 | 0 | 12 | 4 | | |
| | `ondelete` | 13 | 0 | 10 | 3 | | |
| | **total in scope** | **154** | 6 | **92** | **62** | | |
| Ninety-two of the 154 misses are not blind spots. The corpus was looking straight at | |
| those files and pointing somewhere else, and that wants the opposite repair from a | |
| rule that does not exist: a sharper rule, not a new one. A flat miss count hides which | |
| of the two you have, and it had been hiding it since 3.25. | |
| **One rule came out of the diffs.** Two commits removed an `@api.depends` entry the | |
| body never read -- a leftover from an earlier body in both cases. The harm is mild and | |
| stated as mild: a dependency the body cannot use does not make the value stale, it | |
| makes it recompute on writes that could not have changed it. The reason it is worth a | |
| rule is the second commit, where `@api.depends("topic_ids")` sat on a compute that | |
| counted rows on another model. The decorator was the only statement anywhere of what | |
| the value was made from, and it said the dependency was accounted for while nothing | |
| was watching the value at all. | |
| The checker was narrowed three times against OpenSPP, and each narrowing is a class it | |
| now deliberately misses: a body that hands a record to a call, a body that reads a | |
| field the decorator does not declare, and a body that counts rows on another model. | |
| The last one costs the second incident -- it is exactly that shape and is no longer | |
| reported -- because once a compute reads another model, a declared dependency is the | |
| author's best available approximation rather than a leftover, and telling them to | |
| delete it removes the only recompute trigger the value has. | |
| | | | | |
| |---|---| | |
| | Findings on OpenSPP before narrowing | 42 | | |
| | β¦after | **11** (0.05 per 1000 lines) | | |
| | True positives among them, classified by hand | **10 of 11** | | |
| The one false positive is a compute whose docstring reads "Dummy compute to trigger | |
| dependent field recomputation" -- a deliberate canary, depending on fields it does not | |
| read on purpose. The checker cannot tell that from the two dead legacy computes it | |
| correctly found in the same run, and there is no reason it should: both are findings | |
| and only one of them is wanted. | |
| ### 3.36 Where the findings actually land | |
| 3.35 split the missed fixes into silent and spoke, and 92 of 154 were spoke: a | |
| checker fired in the file and the fix went elsewhere. That reads as "the rules | |
| are nearly right", and it was worth finding out whether they are. | |
| `nexa recall --aim` places every finding against the METHOD the fix edited | |
| rather than the file. `subject.units()` already splits a file into the things a | |
| person would name, because `check` needs to say which methods it did not | |
| review; the same split answers a different question here. | |
| **Measured against OpenSPP, 160 in-scope fixes, 235 findings placed:** | |
| | | | | | |
| |---|---|---| | |
| | on-target | 20 | 9% | | |
| | same-symbol | 32 | 14% | | |
| | other-symbol | **183** | **78%** | | |
| Seventy-eight per cent of what the checkers say about a file somebody was | |
| fixing is about a method nobody touched. That is the answer to whether the rules | |
| are nearly right: mostly they are not near at all. | |
| | rule | same | on | other | | |
| |---|---|---|---| | |
| | `sudo-bypasses-record-rules` | 10 | 13 | 18 | | |
| | `ondelete-is-a-decision` | 7 | 1 | 34 | | |
| | `stored-compute-needs-complete-depends` | 5 | 2 | 34 | | |
| | `prefer-sql-constraint` | 2 | 0 | **47** | | |
| `sudo-bypasses-record-rules` is the corpus working: 13 of its 41 placements are | |
| on a line somebody changed. `prefer-sql-constraint` is the opposite and is the | |
| clearest single result here -- 49 placements, 47 of them in methods nobody | |
| edited, and not one on a fixed line. | |
| **What that says about the design.** These rules describe constructs that | |
| deserve thought, not defects. A Many2one without `ondelete` is worth a look and | |
| is usually fine; a Python constraint that could be a SQL one is worth a look and | |
| is usually fine. `fieldtest` at 2.1 per 1000 lines and recall at 4% are the same | |
| fact seen from two sides: the corpus fires often and is rarely about the bug. | |
| That is a defensible thing for a review assistant to be, and it is not what a | |
| recall number measures. Both belong in the documentation rather than one of | |
| them. | |
| ### 3.37 Narrowing the two worst-aimed rules, and what it did not fix | |
| 3.36 ranked the corpus by where its findings land. The two at the bottom were | |
| `ondelete-is-a-decision` (1 on-target against 34 other) and | |
| `prefer-sql-constraint` (0 against 47). Both were narrowed by reading their | |
| findings on OpenSPP rather than by argument. | |
| **ondelete.** The rule's complaint is that a nulled reference "yields False | |
| rather than raising". An author who wrote `if not self.model_id: return` has | |
| already answered it: the reference may dangle and nothing reads through it when | |
| it does. Guarded dereferences are now excluded, at function level, which is the | |
| resolution the dominant idiom actually uses -- a guard at the top of a method | |
| covering the body. | |
| **prefer-sql-constraint.** Two classes where the advice cannot be followed at | |
| all. A search on ANOTHER model tests uniqueness across tables and no UNIQUE on | |
| this one expresses it (that is | |
| `check-constraint-cannot-cross-tables`). And a class already declaring | |
| `models.Constraint("UNIQUE(code)", ...)` or the `_sql_constraints` equivalent | |
| for that column has taken the advice; telling an author to do the thing they did | |
| is how a checker gets switched off. | |
| | | before | after | | |
| |---|---|---| | |
| | `ondelete` findings on OpenSPP | 101 | **20** | | |
| | `prefer-sql-constraint` findings | 85 | 71 | | |
| | Whole-corpus rate per 1000 lines | 2.1 | **1.7** | | |
| **And what it did not do.** Re-placed with `--aim`: | |
| | | before | after | | |
| |---|---|---| | |
| | on-target | 20 (9%) | **20** (10%) | | |
| | same-symbol | 32 (14%) | 26 (12%) | | |
| | other-symbol | 183 (**78%**) | 164 (**78%**) | | |
| Every catch survived, which was the thing to protect: the narrowings cost no | |
| recall at all. The share of findings landing in a method nobody edited did not | |
| move, because 19 fewer noise findings out of 183 is not a change of kind. | |
| Re-placed again on 2026-09-16, after Β§3.43: **19 / 25 / 147 β 10%, 13%, 77%**, | |
| against 162 in-scope fixes and 191 placements. The same answer a third time. The | |
| narrowings took another 17 findings out of `other-symbol` and left `on-target` | |
| where it was, and `sudo-bypasses-record-rules` is placed identically to the run | |
| above it at 10 / 13 / 18, which is what a rule nobody touched should look like. | |
| Three measurements, one conclusion: this corpus is well aimed at a tenth of the | |
| fixes it sees and looking elsewhere for three quarters of them, and no amount of | |
| narrowing moves that β only a rule that does not exist yet will. | |
| `prefer-sql-constraint` is the one to read. It went 0/2/47 to 0/1/42 -- it | |
| barely moved, and it still has not once fired on a line anybody fixed. Its | |
| findings are mostly CORRECT: those Python uniqueness checks do race. They are | |
| simply never what the commit was about. That rule cannot be narrowed into a | |
| defect detector because it is not describing a defect. | |
| ### 3.38 Advisory, a rule property with teeth | |
| So it is labelled instead. `advisory: true` means the rule states a design | |
| preference rather than a defect: following it is better, and not following it is | |
| not a bug. (3.39 makes the label derived rather than authored β it was the | |
| one-bit version of an axis that is now written down in full β but everything | |
| below still holds, and the gating sites still ask this question.) | |
| The label is not decoration. 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, like a suppressed hit | | |
| | the semantic layer | the finding is recorded, the verdict does not follow it | | |
| Reported and not dropped, which is the part worth being deliberate about. The | |
| reader still wants to see it: a Python uniqueness check really does race. What | |
| changes is that nothing fails because of one. | |
| whole-corpus rate 2.1 -> 1.7 (narrowing) -> 1.4 per 1000 lines | |
| advisory 71, counted separately | |
| The precedent is `decided`. A hit on a line carrying another analyser's | |
| suppression marker is printed, excluded from the rate, and never dropped, | |
| because a report that re-raises settled questions gets skimmed. A report that | |
| fails a build over a preference gets switched off, and the real findings go with | |
| it. | |
| **Two grounds, each with its own bar.** One mechanism serving two reasons is | |
| fine; one word meaning two things is not, so the field names both and the rule's | |
| own `why` has to say which it is. | |
| | ground | what it means | the bar | | |
| |---|---|---| | |
| | preference | a better way to write correct code | `recall --aim`: never once fires on a line somebody fixed | | |
| | unproven | a real defect nothing measured here has met | zero findings in `fieldtest`, no incident in `recall` | | |
| `prefer-sql-constraint` is the first: 0 on-target against 47, then 42. | |
| `except-order-follows-the-hierarchy` is the second, and it is worth reading for | |
| how it got here rather than for what it does. A tally over the 62 fixes the | |
| corpus was silent on said nine of them added the name `AccessError` or | |
| `MissingError`, which looked like the largest missing-rule cluster in the list. | |
| The checker was written from that count and catches none of those nine: reading | |
| the diffs afterwards, the before-state is one handler rather than two in the | |
| wrong order. | |
| try: | |
| service.submit(cr) | |
| except UserError as e: | |
| raise HTTPException(status_code=409, ...) | |
| `AccessError` is not mishandled there, it is unhandled -- swallowed by the base | |
| class and reported as 409. Nothing shadows anything, and "caught the base class | |
| without distinguishing a subclass" is not statically decidable, because `except | |
| UserError` is correct in most code. The tally was a bad proxy for the diffs, | |
| which is the same mistake `SCOPE_TOKENS` makes when it counts a commit in scope | |
| for mentioning `ValidationError`. | |
| The construct the checker *does* catch is a genuine defect -- a base class above | |
| a subclass makes the lower handler dead code, and nothing warns -- so the rule is | |
| kept and labelled for exactly what it is: correct, and standing on nothing this | |
| repository has measured. Two of 43 rules carry the label. | |
| ### 3.39 Severity was doing two jobs | |
| `severity` said how bad a thing is if it happens. It was also read as how likely | |
| a finding is the bug, and those are independent. | |
| `prefer-sql-constraint` is where the conflation stopped being survivable. 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 3.38's advisory | |
| flag was the one-bit patch over the gap. | |
| Two fields now, and the flag falls out of the second one. | |
| | field | question | source | | |
| |---|---|---| | |
| | `severity` | how bad if it happens | judgement, unchanged | | |
| | `likelihood` | how likely this is the bug | `recall --aim`, measured | | |
| The grades are read straight off 3.36's table rather than argued: | |
| | rule | same | on | other | grade | | |
| |---|---|---|---|---| | |
| | `sudo-bypasses-record-rules` | 10 | 13 | 18 | `defect` | | |
| | `ondelete-is-a-decision` | 7 | 1 | 34 | `construct` | | |
| | `stored-compute-needs-complete-depends` | 5 | 2 | 34 | `construct` | | |
| | `prefer-sql-constraint` | 2 | 0 | 47 | `preference` | | |
| `construct` is the grade 3.36 described and had nowhere to put: a rule that | |
| recognises the construct and not the defect, right file and wrong line. | |
| `stored-compute` is the pair that needs both fields β critical impact, construct | |
| aim β and no single value can say that. | |
| **Absent means unmeasured, out loud.** Four rules have been placed by `--aim`. | |
| The other thirty-nine grade `unmeasured`, which is a different claim from | |
| "middling", and the build prints the split so the gap cannot be read as | |
| confidence: | |
| likelihood: 5 graded from `recall --aim`, 39 unmeasured | |
| **`advisory` is derived.** It was set by hand in `ENRICH` while the evidence for | |
| it lived in a comment two hundred lines away. It is computed in one place from | |
| the two bottom grades, and `validate` fails if a hand-edited JSON disagrees β in | |
| both directions, because a `defect` rule opting out of the gate is the same bug | |
| as a preference opting into it. The gating sites are untouched: they ask | |
| `advisory`, which is the question they have, and it now answers from a | |
| measurement. | |
| ### 3.40 Encoding the trigger where it is checkable | |
| Two rules are conditional on a fact no single file contains. | |
| `ondelete`'s harm is a read through a reference that was nulled when the PARENT | |
| was deleted. If nothing deletes the parent β if it is master data β there is no | |
| dangling read to have. `check-company`'s harm is a record in company A | |
| referencing company B's. A codebase running one company cannot exhibit it. | |
| Both are answerable from the tree, and answering them is what turns a rule | |
| describing a CONSTRUCT into one describing a DEFECT β for the subset where the | |
| answer is available, and only there. | |
| | trigger | what it does | | |
| |---|---| | |
| | present | the finding is a defect; it is promoted and says why | | |
| | absent | there is no mechanism here; no finding is made | | |
| | unknown | nothing is claimed, and the rule keeps the grade it had | | |
| The third row is the one that matters. An assumed 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` override, or an `ir.model.access.csv` row granting `perm_unlink` β which | |
| is the half of the question reading Python can never answer, because a user | |
| deleting through the UI writes no Python at all. For multi-company: `company_ids`, | |
| `allowed_company_ids`, `env.companies`, an existing `check_company`, or a company | |
| record rule in XML. A plain `company_id` is deliberately not on that list, because | |
| nearly every Odoo model declares one and it separates nothing. | |
| **Absent has two conditions, both strict.** The model must be one the tree | |
| *defines* β a relation to `res.partner` reads unknown however much was walked, | |
| because core Odoo's delete is not in this tree β and the walk must have been | |
| complete. `nexa check` walks one module, which is bounded enough to run on every | |
| review, and is marked incomplete: it can promote a finding and can never drop | |
| one. A model declared in one module and deleted in another would otherwise vanish | |
| from the report on the strength of not looking. | |
| **Measured on OpenSPP.** 2,265 files read for the facts. 492 models declared, 403 | |
| of them deleted somewhere, 89 never β so `absent` is reachable. (The delete set | |
| has 405 members: two are models this tree deletes without declaring, which is why | |
| that number is not 492 minus 89.) Multi-company is in use: 14 Python files and 9 | |
| XML files carry the markers, and 12 files already use `check_company`. | |
| | | before | after | | |
| |---|---|---| | |
| | `ondelete` findings | 20 | 20 | | |
| | `check-company` findings | 12 | 12 | | |
| | whole-corpus rate per 1000 lines | 1.4 | 1.4 | | |
| **The counts did not move, and that is the result.** Every one of the 20 | |
| `ondelete` findings was classified by hand. Ten point at a model OpenSPP declares | |
| and deletes β promoted to `defect`. The other ten point at `res.users`, | |
| `ir.model.fields`, `res.groups`, `res.company`, `mail.template`, `product.product` | |
| and `ir.model`: core models, every one of them, so the trigger reads unknown and | |
| the rule keeps `construct`. Those are exactly the master-data cases the trigger | |
| was written for, and it cannot suppress them, because the evidence that would | |
| justify it is in Odoo's tree and not in this one. | |
| So the trigger re-graded half the findings and silenced none of them here. The | |
| suppression path is real and tested; this tree just does not exercise it. Saying | |
| that is worth more than a narrowing number, because the next tree may. | |
| ### 3.41 Ranking the report by the prior | |
| The cheapest of the three and it needed no new measurement. Findings printed in | |
| line order tell the reader nothing about which rule is worth reading; the grades | |
| from 3.39 were already sitting there. | |
| So the grade leads, and line order survives inside it. `sudo-bypasses-record-rules` | |
| is read before `prefer-sql-constraint` because history says which is more likely | |
| to be the bug. `check` prints both axes as columns, and `fieldtest` prints the | |
| tally per grade. | |
| 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 | |
| An unknown grade sorts last rather than raising. Ordering a report is not the | |
| place to fail a run, and `validate` has already refused an unknown grade against | |
| the schema enum. | |
| --- | |
| ### 3.42 A review of the tool itself, and the silence it was keeping | |
| Everything above measures the CORPUS. This is the first pass over the code that | |
| runs it, and the worst thing it found was not a wrong answer but an absent one. | |
| **A syntax error anywhere in a file erased every AST finding.** Each semantic | |
| checker opens with `tree, _ = parse_python(code)` and returns `[]` when that | |
| fails. The error goes to `_`. Nine checkers then agree there is nothing to say, | |
| `scan_source` returns an empty list, and `nexa check` prints *no checker findings | |
| in the file* and exits 0. | |
| a file with a real stale-compute bug 1 finding, exit 1 | |
| the same file, plus a trailing `def oops(:` 0 findings, exit 0 | |
| That is a false clean, which is the single failure mode this repository exists to | |
| not have, and it fires exactly when people run `check`: on code they are still | |
| editing. It also contradicts the contract every layer is held to β report what | |
| was checked AND what could not be, with a reason β because here nothing was | |
| reported at all. | |
| `scan_source` now parses once, up front, and hands back a gap instead of silence: | |
| the file does not parse at line 13 (invalid syntax), so 9 semantic | |
| checker(s) never ran -- nothing they cover has been checked here | |
| `check` prints it, refuses the word clean, and exits 1. `fieldtest` counts those | |
| files apart from the ones it read, so their lines cannot sit in a denominator | |
| nothing examined. `scan_source` already collected the checkers that RAISE, on the | |
| argument that a swallowed crash looks exactly like a clean file. The case it had | |
| no channel for was the checker that quietly declines, which looks exactly the | |
| same and is far more common. | |
| **A dotted `@api.depends` did not cover its own prefix.** `@api.depends('a.b')` | |
| registers the whole chain β the ORM recomputes when `a` changes as well as when | |
| `a.b` does β so a body reading `rec.a` needs nothing further. The comparison was | |
| an exact string match, so it demanded the prefix be written out again and called | |
| correct code stale. Straight from OpenSPP: | |
| ```python | |
| @api.depends("requested_date", "definition_id.sla_days") | |
| def _compute_sla_deadline(self): | |
| for record in self: | |
| if record.definition_id and record.definition_id.sla_days: # flagged | |
| ``` | |
| | `stored-compute-needs-complete-depends` | Hits | | | |
| |---|---|---| | |
| | OpenSPP, before | 104 | | | |
| | OpenSPP, after | 62 | 42 removed, **40% of the rule's output** | | |
| | OCA, before | 67 | | | |
| | OCA, after | 61 | | | |
| Not one true positive went with them, and no other rule moved by a single hit. | |
| The converse still fires, and has to: a declared `line_ids` does NOT cover a read | |
| of `line_ids.price`, which is the value that goes stale and the whole reason the | |
| rule exists. | |
| **Why `validate` could not see it.** The rule's own `correct` sample declared | |
| `('line_ids', 'line_ids.price', 'line_ids.qty')`. The bare `line_ids` is | |
| redundant β the two dotted paths already cover it β and it was the only reason | |
| the self-test passed. The sample was written around the bug. It is narrowed now, | |
| so the self-test exercises the thing it claims to. | |
| This is the second time that pattern has cost something, and it generalises: a | |
| rule's self-test proves the checker catches its OWN sample and nothing else. The | |
| 42 came from classifying real findings by hand, which is the only method that has | |
| ever found one of these. | |
| **A slash after a keyword was scanned as division.** `_REGEX_OK_AFTER` held | |
| punctuation only, so in `return /[(]/.test(s)` the slash read as an operator and | |
| the regex BODY was scanned as code β its unpaired bracket reported as an | |
| unbalanced delimiter, at `high`, in a file that is fine. In `nexa run` that buys | |
| an escalation to the 30B over nothing. It only bites when the class is | |
| unbalanced, which is why `/^[^@]+@[^@]+$/` survived it: those brackets happen to | |
| pair. `_prev_significant` now returns the trailing word rather than one | |
| character, and fourteen keywords join the set. | |
| **A decorator nothing can read statically is a gap, not a finding.** | |
| `_decorator_args` returned `[]` both for "no such decorator" and for | |
| `@api.constrains(*FIELDS)`, which the caller could not tell apart β so every | |
| field the body read became a missing declaration, in a sentence that read | |
| "declares only nothing". Three answers now: absent, unreadable, or the list. The | |
| middle one goes to the gap channel. It found a real case on its first run, | |
| `endpoint_route_handler.py` in OpenSPP. | |
| **And the denominator was wrong.** `fieldtest` counted `body.count(NL) + 1`, | |
| adding one line per file that is not in the file. Only a file ending in a | |
| newline was affected, which is 1,301 of OpenSPP's 1,353 and 602 of OCA's 606 β | |
| 0.57% of the denominator, and every per-kloc rate ever quoted here was divided by | |
| it. Counted as `wc -l` counts now. | |
| | Re-measured 2026-09-15 | Hits | Per 1000 lines | | |
| |---|---|---| | |
| | OpenSPP 2 (Odoo 19), 1,353 files | 323 β **281** | 1.4 β **1.2** | | |
| | OCA 18.0, 606 files | 135 β **116** | 3.9 β **3.4** | | |
| The OpenSPP pair is a clean before/after on one checkout. The OCA pair is not | |
| quite: those repos moved between the two runs, and only six hits of that drop are | |
| the prefix fix. What makes the comparison worth printing anyway is that the file | |
| count is identical at 606 and the corrected line count lands within sixteen lines | |
| of the old one once its phantom line per file is taken back out. | |
| Neither number is where this ends: Β§3.43 classifies the OCA hits and narrows four | |
| checkers against what that found, taking OCA to 100 and OpenSPP to 269. | |
| **Seven smaller ones**, none of which changed a published number: `telemetry.emit` | |
| could raise through a docstring promising it never does, and inside `span()` the | |
| resulting `TypeError` REPLACED the exception the caller was already handling; a | |
| malformed `NEXA_EVIDENCE_KEEP` raised out of `import evidence` and took every | |
| command with it; `nexa check <directory>` reported `[Errno 13] Permission denied` | |
| at exit 1 where a bad argument is 2; `nvidia-smi` ran without a timeout, in the | |
| one command people reach for when the GPU is wedged, and its output was unpacked | |
| as though there were only ever one card; `nexa index` skipped `.github/` because | |
| the filter was a substring test; comma-separated version bounds were combined | |
| with OR, so `>=16.0,<=18.0` matched 25.0; and `retrieve` indexed a severity table | |
| with `[]` over rows that come from SQLite rather than from the validated JSON. | |
| **The banner printed twice** on `doctor` and `build`. Under `python nexa.py` the | |
| name `nexa` is unclaimed, so `nexa_cmds` doing `import nexa` loaded a second copy | |
| of the module β its own `COLOR`, its own `UNICODE`, its own `_BANNER_SHOWN` β and | |
| each copy believed it was the first to print. `tests/test_cli.py` is named for | |
| that invariant and could not see it, because under pytest the module is imported | |
| once under its own name. Fixed by aliasing the two names to one module, which is | |
| what the rest of the file already assumes. | |
| The suite went from 531 tests to 546. Twelve of the fifteen fail against the | |
| commit this started from; the other three assert the converse behaviour that had | |
| to keep working, and passed all along. | |
| --- | |
| ### 3.43 Taking the worklist the classification produced | |
| Β§3.42 re-measured. Section 6 classified all 116 OCA hits by hand and found 24 false | |
| positives in seven classes. Four of those classes were a single predicate on | |
| something already in the file, which makes them a worklist rather than a complaint. | |
| | | Predicate | Removed | | |
| |---|---|---| | |
| | `TransientModel` | is the enclosing class a `TransientModel` | 7 | | |
| | `related=` mirror | does the field declare `related=` | 4 | | |
| | No stored column | `compute=` without `store=True` | 3 | | |
| | Message-only read | is the field read only to build a message | 1 | | |
| | Independent cursor | was this cursor opened in this function | 1 | | |
| The first three are about what the database actually holds. A wizard's row is | |
| vacuumed, so a stored value cannot go stale in it and a nulled reference cannot be | |
| read from it much later β the harms `stored-compute-needs-complete-depends` and | |
| `ondelete-is-a-decision` are named for. A `related=` field mirrors a value that | |
| belongs to another record, so the constraint belongs on the source, which the same | |
| run already flags in its own right; reporting both was reporting one decision twice. | |
| A `compute=` without `store=True` produces no column at all, and `ondelete` and | |
| `check_company` are instructions to a column. | |
| The fourth is a gap in an exemption that already existed. `_message_only_attrs` | |
| dropped a field read that happens inside a `raise`, on the grounds that phrasing an | |
| error does not decide whether the invariant holds. It only knew the inline spelling: | |
| ```python | |
| msg = self.env._("... %(journal)s ...", journal=journal.display_name) | |
| raise ValidationError(msg) | |
| ``` | |
| builds the message one line earlier, and `display_name` was reported as a field the | |
| constraint depends on. | |
| The fifth is the one worth arguing about. `commit-inside-a-loop` has exactly two | |
| instances in everything measured here, and they are the two sides of its own rule: | |
| ```python | |
| self.env.cr.commit() # OpenSPP, in a batch expiry cron -- the caller's | |
| # transaction, split. A true positive, and its author | |
| # marked it `# nosemgrep` and kept it. | |
| new_env.cr.commit() # OCA batch_unlink, on a cursor opened three lines | |
| # earlier from Registry(...).cursor(). Not the | |
| # caller's transaction at all. | |
| ``` | |
| The rule says *let the caller own the transaction*, and the second already does. The | |
| exemption is bound positively β a name is only independent when this function is | |
| watching it be opened β rather than by treating anything not spelled `self.env.cr` | |
| as somebody else's. Scoped per function, too, so a `new_cr` opened in one method | |
| cannot excuse a commit in another that reuses the name. | |
| **What it did, on both trees:** | |
| | | Hits | Per 1000 lines | | |
| |---|---|---| | |
| | OCA, before | 116 | 3.4 | | |
| | OCA, after | **100** | **2.9** | | |
| | OpenSPP, before | 281 | 1.2 | | |
| | OpenSPP, after | **269** | 1.2 | | |
| and on the classification that drove it: | |
| | | Before | After | | |
| |---|---|---| | |
| | True positives | 79 | **79** | | |
| | False positives | 24 | **8** | | |
| | Undecidable from one file | 13 | 13 | | |
| **Not one true positive went with them.** That is the whole test of a narrowing, and | |
| it is checkable only because the classification is a file with a verdict on every row | |
| rather than three totals in a document. `validate` still passes, every checker still | |
| catches its own wrong sample, `crosscheck` is still 0 cross-hits and `adversarial` is | |
| still 101/101 under mutation β but none of those four would have noticed a lost true | |
| positive on real code, which is exactly the blindness Β§3.42 was written about. | |
| The eight that remain are in section 6 with the reason each was left. Two of them are | |
| deliberate: narrowing `sql-needs-query-parameters` to ignore an interpolated | |
| identifier means deciding which holes in an f-string are table names, and getting | |
| that wrong hides an injection. That is the one checker here where narrowing could | |
| conceal the thing it exists to find. | |
| --- | |
| ### 3.44 The other twenty-five rules had never been fired at real code | |
| Every measurement in this repository was `--stack odoo`. Both field tests, the | |
| classification, both eval artifacts. The TypeScript, JavaScript and Python rules β | |
| twenty-five of forty-four β had their own self-tests, `crosscheck` and | |
| `adversarial` behind them and nothing else. That is exactly the state the Odoo | |
| rules were in at 11.3 hits per thousand lines, before Β§3.18 fired them at somebody | |
| else's source and took them to 2.9. | |
| Pointing the same method at them found four defects in the measurement path before | |
| it found anything about the rules. | |
| **`fieldtest --stack javascript` answered "0 files" on any Odoo tree.** `SKIP_DIRS` | |
| contained `static`, and in Odoo every line of first-party JavaScript a module ships | |
| lives under `static/src` β 65 of OpenSPP's 69. The list was stack-independent and | |
| what counts as "never the code under review" is not: `static` is assets when you | |
| are reviewing Python and the whole subject when you are reviewing JavaScript. It is | |
| stack-aware now, with a bundle guard so the vendored libraries under `static/lib` | |
| are not read as though somebody here wrote them. The guard is ordinary JavaScript | |
| convention rather than anything Odoo-shaped β the name says `min` or `bundle`, or | |
| one line is longer than any line a person types: | |
| | | longest line | | |
| |---|---| | |
| | OpenSPP's five vendored libraries | 32,071 / 95,621 / 198,502 / 375,248 | | |
| | its 59 first-party files under `static/src` | 87 | | |
| **A test named by its file was reviewed as shipped code.** `_is_test_path` reads | |
| directory components, which is where Python and Odoo put tests. The JavaScript | |
| convention puts them beside the thing they test, and 45 of the first 355 TypeScript | |
| hits were in files like `src/hono.test.ts`. Anchored rather than substring, because | |
| `latest_version.py` contains `test_`. | |
| **Two matches on one line were two findings.** A pattern rule appended one | |
| `SourceHit` per regex match and the evidence *is* the line, so a signature | |
| declaring two `any` parameters reported itself twice, word for word β 34 of those | |
| 355. The semantic checkers have deduplicated since they were written; the pattern | |
| path does now. | |
| **`assert-is-not-validation` named the line above the assert.** Its pattern began | |
| by consuming the preceding newline, and `match.start()` is what `scan_source` turns | |
| into a line number. A lookbehind fixes it without changing what matches. It was the | |
| only pattern in the corpus that began with a newline, and it is a critical rule. | |
| **Then the rules.** | |
| | Stack | Files | Lines | Hits | Per 1000 | | |
| |---|---|---|---|---| | |
| | JavaScript, Odoo frontend | 62 | 9,304 | 45 | 4.8 | | |
| | TypeScript, hono and trpc | 781 | 73,762 | 277 | 3.8 | | |
| | Python, llm-router and OpenSPP's scripts | 42 | 10,225 | 3 | 0.3 | | |
| JavaScript started at 83 hits and 8.6 per thousand. One narrowing took it to 45: a | |
| default that is *itself* falsy destroys nothing. A value `|| 0` is zero when the | |
| value is zero, and a value `|| ""` is blank when it is blank, so there is no | |
| legitimate value being replaced β which is the entire rule. That was 31 of the 69 | |
| hits on the dominant rule. All 45 are classified in | |
| `classification-js-2026-09.json`: **21 true positives, 24 false.** | |
| Twenty-one of those twenty-four are one judgement. `x || "label"` where the left | |
| side is a URL, a colour, a mode or an error message: blank is the absence of | |
| configuration there, not a value somebody stored. But the rule's own `why` names | |
| "an intentionally blank note", so the harm is real in principle and not | |
| mechanically separable from these. Left and written down, for the same reason the | |
| SQL identifiers were in Β§6. One codebase does not settle it. | |
| **TypeScript is measured and not yet classified, and the distinction is the | |
| method.** Narrowing before classifying is the mistake this project exists to not | |
| make. What the samples already show is that two of the three loudest rules fire on | |
| the construct without testing the context their own text names: | |
| - `unknown-not-any-at-boundaries` fires on every `: any`. The rule says "the body | |
| of an incoming webhook, a request payload, a handler parameter"; the samples are | |
| conditional types, a logger's rest parameter, a promise's reject callback. | |
| - `validate-env-at-startup` fires on every `process.env` read. The rule says never | |
| read it *deep inside* and wants it parsed once at startup β so a playwright | |
| config reading `process.env.CI` is the rule being followed. 109 of its 115 hits | |
| are in `examples/` demo apps. | |
| - `types-are-erased-at-runtime` fires on a double cast and on a parsed response | |
| asserted rather than checked. Precise, and those look like true positives. | |
| That is `onchange-is-not-validation` firing on all sixteen `@api.onchange` | |
| declarations again, in a different language, found the same way and at the same | |
| stage. Classifying the 277 is what decides whether either rule can be narrowed or | |
| needs a context a regex cannot ask for β and there is no JavaScript parser in the | |
| standard library, which is the limit Β§6 has always named. | |
| --- | |
| ### 3.45 The corpus was advising a constraint Odoo 19 does not create | |
| `--aim` said `prefer-sql-constraint` had never once landed on a line anybody | |
| fixed: 47 placements, then 42, then 43, and zero on-target every time. Β§6 read | |
| that as a rule describing a preference rather than a defect. Reading the misses | |
| instead of the number found something else. | |
| OpenSPP commit `208d9758`: | |
| > *fix(change_request_v2): enforce reason-document uniqueness with | |
| > models.Constraint* β the `(cr_type_id, reason)` rule was declared via the | |
| > legacy `_sql_constraints` attribute, **which Odoo 19 ignores** β the registry | |
| > only logs a warning and the constraint is never created, so duplicate rules | |
| > could be saved silently since 19.0.3.0.0. | |
| It shipped with a pre-migration to delete the duplicates that had accumulated, | |
| and with a lint rule of their own, because *"Nothing in CI caught the legacy | |
| attribute."* Their tree is 84 `models.Constraint` declarations against zero live | |
| `_sql_constraints`; the ten remaining mentions are migrations, tests, and | |
| comments reading "Odoo 19: Use models.Constraint". | |
| **This corpus was advising the dead spelling.** `prefer-sql-constraint` declares | |
| `odoo_versions: ">=16.0"` and its `correct` sample was: | |
| ```python | |
| _sql_constraints = [ | |
| ('code_uniq', 'UNIQUE(code)', 'Code must be unique.'), | |
| ] | |
| ``` | |
| `onchange-is-not-validation` β the rule the README opens with β carried the same | |
| sample, and worse, a `satisfied_pattern` of `@api\.constrains|_sql_constraints`. | |
| On 19 that cleared an onchange whose only "enforcement" was a declaration the | |
| registry throws away. A rule whose whole subject is *a check that looks like it | |
| holds and does not* was accepting one as the guard. | |
| Both now advise `models.Constraint`, which arrived in Odoo 17, with the legacy | |
| spelling kept as a comment naming the version it still works on. So do the two | |
| finding messages, which were still telling the reader to put the UNIQUE entry in | |
| `_sql_constraints`. | |
| The `satisfied_pattern` was narrowed to `models\.Constraint` too, and that part | |
| was **wrong and is reverted**. A `satisfied_pattern` is a plain regex with no way | |
| to read the module's series, and `_sql_constraints` IS a real constraint on 16 | |
| through 18. The next field test said so immediately: OCA's `account_loan`, branch | |
| 18.0, a genuine `_sql_constraints` block, an onchange properly backed by it, and | |
| a critical rule newly firing on it. The pattern accepts all three spellings | |
| again, and the 19 hole is closed where the series is legible instead. | |
| **And the checker was reading the dead declaration as satisfaction.** | |
| `_columns_already_constrained` treated a `_sql_constraints` block as the column | |
| being spoken for, which on 19 means exempting a class on the strength of nothing. | |
| It now ignores the legacy form when the module pins 19 or later, which required | |
| carrying `odoo_series` in the tree facts β `tree_facts` reads it from every | |
| manifest in the walk and reports it only when they agree, because a tree holding | |
| a 16 module beside a 19 module has no single answer. | |
| | `@api.constrains` uniqueness check, beside⦠| series | fires | | |
| |---|---|---| | |
| | `_sql_constraints` | 16.0 | no β the constraint is real | | |
| | `_sql_constraints` | 19.0 | **yes** β the constraint does not exist | | |
| | `models.Constraint` | any | no | | |
| | either | unknown | no | | |
| Unknown keeps the old reading deliberately. Firing on every 16, 17 and 18 module | |
| that did exactly the right thing is a worse trade than missing the 19 case, and | |
| `check` already reports an absent manifest as a gap. | |
| **What this does not do, and the first draft of this section said it did.** It | |
| does not catch `208d9758`. That file has no `@api.constrains` at all β the author | |
| went straight to `_sql_constraints` and the rule about preferring one has nothing | |
| to say. Catching that incident needs a rule that does not exist yet: *the legacy | |
| attribute is ignored from 19*. The fix here closes the narrower case, a Python | |
| check standing beside a constraint that will not be created, and it is a genuine | |
| one; it is not the one the commit was about. | |
| It also changes nothing on OpenSPP, whose 71 findings are Python checks with no | |
| SQL constraint beside them at all. That is the right outcome rather than a null | |
| result: the tree already migrated. | |
| ### The cull, and what the evidence would not support | |
| The plan had been to split `prefer-sql-constraint` β 43 of its 71 findings are | |
| `search_count` uniqueness checks, which genuinely race, and 28 are a bound | |
| against a literal, which does not. Instead the corpus was searched for rules to | |
| remove outright. | |
| **No two rules duplicate each other.** Across all five field tests β 226,937 | |
| lines of OpenSPP, 33,960 of OCA, 73,762 of TypeScript, 9,304 of JavaScript, | |
| 10,225 of Python β **not one pair of rules ever fired on the same line.** File | |
| level co-occurrence tops out at 43% and is volume rather than overlap: the | |
| highest-firing rule appears in most pairs. Textual similarity between any two | |
| rule statements on the same stack tops out at 0.40, and the pairs that reach it | |
| share a name prefix (`constrains-*`) rather than a subject. `crosscheck` already | |
| proved no pattern fires on another rule's correct sample; field evidence now says | |
| the same thing about code nobody wrote for this corpus. | |
| **One rule went.** `javascript.validate-at-the-boundary-once`, on three grounds | |
| together: | |
| - Its checker cannot express its rule. The rule is *parse untrusted input at the | |
| entry point*; the pattern is | |
| `if\s*\(![\w.]+\)\s*return[\s\S]{0,200}?if\s*\(![\w.]+\)\s*return` β two guard | |
| clauses within two hundred characters. Nothing in it can see "untrusted", which | |
| is the whole rule. | |
| - Its only field evidence is two hits, **both false**: `if (!this._registryRestricted) return;` | |
| twice in one class, an internal boolean flag and an ordinary early return. | |
| - It has no upstream source β one of three rules resting on experience alone β | |
| and its subject is covered by two that do have one and that scored 6 true out | |
| of 6 each: `json-parse-returns-any` and, on the TypeScript side, | |
| `types-are-erased-at-runtime`. | |
| **Nothing else met that bar, and the near misses are worth naming.** Sixteen | |
| rules fired zero times, and for almost all of them that says where the tool was | |
| pointed rather than anything about the rule: | |
| `python.eval-and-pickle-execute-their-input` and | |
| `subprocess-shell-true-is-injection` found nothing because llm-router does | |
| neither; the three `typescript.odoo-*` rules found nothing because hono and trpc | |
| are not Odoo clients. Removing those would be deleting good rules for being | |
| aimed at code nobody measured. | |
| `prefer-sql-constraint` stays, against its own numbers. It is the clearest | |
| "useless" in the corpus by recall β 73 findings, zero catches, zero on-target | |
| three times running β but 43 of those findings are Python uniqueness checks that | |
| race, and a race nobody has hit yet produces no fix commit for `recall` to score | |
| against. The zero is evidence that `--aim` cannot see this class, not that the | |
| class is not there. It is advisory, so it is reported everywhere and gates | |
| nothing, which is the right place for a finding whose harm is real and latent. | |
| | | before | after | | |
| |---|---|---| | |
| | Rules | 44 | **43** | | |
| | Machine-checkable | 41 (93%) | 40 (93%) | | |
| | JavaScript findings, classified | 21 true / 24 false | **21 true / 22 false** | | |
| ### 3.46 Narrowing to Odoo, and composing the Python rules into it | |
| **Decided 2026-09-16.** The corpus had four stacks. It has one, and the eight | |
| plain-Python rules are inside it rather than beside it. | |
| **Why narrow.** Β§3.44 fired the other twenty-five rules at real code for the first | |
| time, and the answer decided this. JavaScript classified out at **21 true against 22 | |
| false**. TypeScript was measured at 3.8 per thousand lines and never classified, so | |
| its number said nothing. Against those, the Odoo half carries 87 true / 15 false on | |
| its two flagship rules across two ecosystems. The edge is Odoo ORM semantics; | |
| everywhere else this corpus competes with mature tools and loses. | |
| Removing them rather than labelling them experimental is the part worth arguing. A | |
| rule that costs nothing at query time is not free: it is a claim the README has to | |
| carry, a number somebody will quote, and a file `nexa check` will accept and then say | |
| nothing useful about. A `.ts` file now raises `subject.Unreviewable` and is refused by | |
| name, because **an empty finding list reads as a clean bill of health**, and silence | |
| about a file nobody checked is the one lie this design exists to prevent. | |
| **Why the Python rules were composed in instead.** Rules were selected by strict | |
| equality on `stack` (`corpus.py`), so an Odoo module got the 19 ORM rules and none of | |
| the 8 Python ones. Odoo modules are Python. `fieldtest --stack python` over OpenSPP | |
| found **32 hits `--stack odoo` never reported**. | |
| "ruff covers it" was the first answer and it does not hold. OpenSPP's own `.ruff.toml` | |
| selects `B, C90, E501, I, UP` plus defaults β `S` and `DTZ` are off β so six of the | |
| eight are genuinely unreported in that deployment, and a narrow selection is what most | |
| Odoo shops run. | |
| **What it cost, measured before shipping.** The composition was held to the bar in | |
| Β§3.43: 50 new findings across OpenSPP and OCA, every one read in its own file. | |
| **26 true, 24 false**, in `artifacts/classification-python-2026-09.json`. | |
| | | | | | |
| |---|---|---| | |
| | OpenSPP live findings | 162 | **194** (0.71 β 0.9 per kloc) | | |
| | OCA live findings | 102 | **120** (3.0 β 3.5 per kloc) | | |
| | Corpus precision, composed-in half | β | 26 true / 24 false | | |
| Twenty of the twenty-six are one rule, `naive-and-aware-datetimes-do-not-compare`, and | |
| one fact: an Odoo `Datetime` is **naive UTC by contract**, so a naive local datetime | |
| reaching one is off by the server's offset. Latent on a UTC-clocked server, wrong on | |
| any other, and OpenSPP's history carries two fixes of exactly it. | |
| Three rules fired and were never right, and the most interesting is | |
| `decimal-for-money-not-float`: Odoo's `Monetary` field **is** a float, and the | |
| framework's answer to precision is `float_round`/`float_compare`, not `Decimal`. The | |
| rule fires on the idiom and names a fix that cannot be taken. Three findings do not | |
| settle a checker either way, so it is recorded rather than removed. | |
| **Two defects the classification found that no test would have.** `scan_source` stripped | |
| `#` comments before matching and not string literals, so `eval()` named in a docstring | |
| read as code. And suppression read any marker within a simple statement without | |
| asking which rule it named, so a `# nosemgrep: odoo-sudo-without-context` silenced a | |
| datetime finding four lines above it β the mute button of Β§3.42 reached from the other | |
| side. Both were named in the README first, and both are closed in Β§3.47. | |
| **What the id prefix means now.** `python.decimal-for-money-not-float` carries | |
| `stack: odoo`. The prefix is **provenance** β where the rule came from and which | |
| documentation `check_sources` verifies it against β and the stack is **selection**. The | |
| validator's old invariant was "id prefix equals stack"; it is now "id prefix is one of | |
| `corpus.ORIGINS`". Keeping the ids is what keeps every committed classification, | |
| `LIKELIHOOD` grade and evidence bundle pointing at the same rule: renaming the subject | |
| of a measurement is the quiet way a number stops meaning what it says. | |
| **One rule had to be reworded.** Retrieval is scoped to a stack, so merging them put | |
| 27 rules in competition where 19 had been. `prefer-sql-constraint` ranked first for | |
| *"stop anyone entering a negative quantity on a sale order line"* against 19 and | |
| stopped being retrieved at all against 27 β that probe is the generic validation | |
| question every constraint rule here answers. Reworded to the one only it answers. | |
| **27 of 27 retrievable, 25 in the top 3.** | |
| ### 3.47 Reading the value instead of the name, and the rest of the sudo sentence | |
| **Decided 2026-09-21.** Six false-positive causes the README had named were closed in | |
| one pass, and the pass was ordered by cause rather than by rule, because the six had | |
| three causes between them. | |
| **The shared cause.** `float-comparison-needs-float-utils` (2 true, 5 false), | |
| `path-join-does-not-contain-a-path` (0 of 4) and `decimal-for-money-not-float` (0 of 3) | |
| each matched a variable's *name* and treated that as evidence about its *value*. All | |
| five float falses compared an integer to zero β `search_count`, a `read_group` | |
| `<field>_count`, `cr.fetchone()[0]` of a `SELECT COUNT(*)`, a `fields.Integer` β and | |
| three of the five plus both true positives were spelled `total == 0`. No name could ever | |
| have separated them. The assignment can. | |
| So `semantics.py` grew a local value trace: which names a scope binds, what each was | |
| assigned, whether an expression is certainly a whole number, whether its root came from | |
| outside the file. One file, one function, no imports followed, no types inferred, and | |
| the limit is written into the limitations section rather than discovered later. Three | |
| regexes became AST passes on top of it. Every answer the trace gives that *suppresses* a | |
| finding must be certain β a name assigned twice is an integer only if both assignments | |
| are β because a suppressing guess is the direction that hides. | |
| **The window that crossed a function.** `onchange-is-not-validation` was 4 true, 3 | |
| false, and the README's reading of the three ("the write is to a Many2one") was wrong. | |
| Each is a four-line onchange that assigns a field, followed by an action method that | |
| raises. The regex read `[\s\S]{0,400}` past the decorator; `_onchange_variable_id` is | |
| 200 characters long and the `UserError` it matched belongs to `action_remap`. A regex | |
| has no way not to do that. Scoped to the decorated function by an AST pass, the three | |
| are silent, and three new findings surfaced and were read: a phone-number validation | |
| living only in an onchange with no constraint anywhere in the file; a | |
| `_check_date_collected` onchange raising `exceptions.ValidationError`, a dotted spelling | |
| the regex's `\w*` never matched; and one that clears a field and returns the warning | |
| dict. The finding anchors on the decorator, because that is where the rule is and where | |
| a marker about it gets written β a `FunctionDef`'s own `lineno` is the `def`, one line | |
| down, and reporting there lost a suppression a test already relied on. | |
| **Two infrastructure defects, fixed once.** Docstrings are now emptied before any | |
| pattern sees the file β docstrings only, since `_SQL_COLUMNS` reads triple-quoted SQL | |
| on purpose β with quotes, newlines and length preserved so no reported line or column | |
| moves. And a suppression marker that *names* a code with words in it | |
| (`odoo-sudo-without-context`) now covers only rules sharing one of those words, vendor | |
| and language words excepted; a code with no words (`B608`, `E501`) or prose after the | |
| colon cannot be compared with anything and covers its line exactly as before. The | |
| direction of error is stated in `_marker_is_about`: a code the function cannot relate | |
| to a rule leaves the finding reported. | |
| Fixing that surfaced a third defect nothing had reached before. Every statement span | |
| borrows the comment line above it, so consecutive statements overlap by one line, and a | |
| finding on the first line of one statement is also inside the borrowed line of the next. | |
| Two spans of equal width tied, and the winner was whichever `ast.walk` reached first. | |
| On `spp_api_v2/wizards/show_secret_wizard.py` the `return` on line 38 won the marker on | |
| line 36 away from the write on 37. A span whose statement *covers* the line now beats | |
| one that only borrows it. | |
| **The rest of the sudo sentence.** The rule says three things β scope `sudo()` to the | |
| narrowest operation, re-apply company filtering by hand, never pass a sudo recordset | |
| onward β and the checker tested only `.sudo().search()`. Β§3.30's harvest put `.sudo(` at | |
| 70 missed against 6 caught, 47 of the 70 in files where a checker fired elsewhere; the | |
| README's own named miss, *"remove sudo() when importing record"*, is a `create`. Counted | |
| on the OpenSPP tree, the query clause has 116 call sites and the unchecked one 136. | |
| `sudo_search_drops_a_real_rule` became `sudo_drops_record_rules` and checks all three: | |
| queries, touches (`create`, `write`, `unlink`, `copy`, `read`), and a sudo recordset | |
| bound to a name and then returned or passed as an argument β the bare name only, since | |
| telling `return p.filtered(...)` from `return p.mapped('name')` means knowing which | |
| recordset methods return recordsets, and a new clause on a critical rule should | |
| under-report. | |
| A query decides *which* records exist for you and a touch acts on ones you already hold. | |
| That line is where the measurement fell, so it is where the code divides. | |
| The record-rule trigger of Β§3.40 gates every clause, and it needed one more thing to do | |
| so: `Plan.sudo().create(...)` names no model, so the trigger read UNKNOWN and could not | |
| suppress. The receiver is now followed through its assignment β `Plan = | |
| self.env["spp.case.plan"]` a few lines up β and 12 of the 15 unresolved receivers in the | |
| file that produced them resolve. `check_compliance` also runs semantic checkers now; it | |
| ran patterns only, so every rule that moved to an AST pass had silently left the | |
| compliance check of generated answers, including the onchange rule that function's own | |
| docstring gives as its reason for existing. | |
| **The `company_id` exemption became the rule's own fields.** The old test for "this call | |
| already re-applied the scoping" was the literal word `company_id`, from the rule's | |
| correct sample, and Β§3.40 recorded the cost: 14 of 20 surviving findings were scoped by | |
| a group, a partner or an area and read as unscoped. It called a narrowing a worklist | |
| entry because "deciding in general that a domain is already scoped means evaluating it, | |
| and a narrowing that guesses would cost the 6 true positives this keeps." | |
| There is a version that does not guess. `context` already parses every `ir.rule` in the | |
| tree to decide whether one filters; it now also records **which fields each domain is | |
| written about**, and the checker compares the call against those instead of against one | |
| hardcoded word. Not one OpenSPP case model has a company rule at all β they are governed | |
| by `[('case_worker_id', '=', user.id)]` and `[('case_id.case_worker_id', '=', user.id)]` | |
| β so a create naming `case_worker_id` or `case_id` lands inside the caller's own | |
| visibility, and that is the rule's advice followed in the only spelling that model has. | |
| `company_id` stays as the fallback where the rules could not be read, so nothing exempt | |
| before stops being exempt now. | |
| Two details cost a true positive each before they were right. The comparison reads the | |
| call's **arguments**, not the unparsed call: the receiver carries the model name, and | |
| `spp.group.membership` contains the word `group`, which is one of that model's own rule | |
| fields β it exempted an unscoped sweep of every membership in the database, classified | |
| true by hand. And an argument that is a bare name is followed to what it was assigned, | |
| because `create(vals)` otherwise hides every field it sets behind four letters. All six | |
| true positives survive, checked explicitly. | |
| **What the re-run says**, against the artifacts of Β§3.43 and Β§3.46: | |
| | | OpenSPP before | after | OCA before | after | | |
| |---|---|---|---|---| | |
| | `float-comparison` | 7 | **2** | 1 | **3** | | |
| | `onchange-is-not-validation` | 7 | 7, all three falses replaced | β | β | | |
| | `decimal-for-money` | 2 | **0** | 1 | **0** | | |
| | `path-join` | 1 | **0** | 3 | **0** | | |
| | `eval-and-pickle` (docstring) | 1 | **0** | β | β | | |
| | `datetimes` wrongly suppressed | 1 | **0** | β | β | | |
| | `sudo` deciding / advisory / decided | 2 / 0 / 62 | **2 / 11 / 117** | 7 / 0 / 0 | **6 / 10 / 0** | | |
| | Rate per thousand lines | 0.9 | **0.8** | 3.5 | **3.4** | | |
| The OCA float number went *up*, and that is the old `satisfied_pattern` clearing a | |
| whole file on a `float_compare` anywhere in it. The three are `Monetary` and `Float` | |
| fields compared with `!= 0` in files that call `float_compare` somewhere else, each read. | |
| **What the classification says, and what was done about it.** All 23 live sudo findings | |
| were read by hand against their own files, in `artifacts/classification-sudo-2026-09.json`. | |
| **0 true, 23 false.** The touch and escape clauses have not met a real one in 260,897 | |
| lines. Every finding they made is code where the caller is an administrator by | |
| construction β a demo-data seeder, an accounting wizard behind | |
| `group_account_manager` β or a framework model with no tenant boundary: an | |
| `ir.actions.act_window`, an `ir.sequence.date_range`, a postgres tuning row. | |
| So the two new clauses emit `unproven` **per finding**. That grade already exists for | |
| exactly this position β "a real defect nothing measured here has met yet, so it is | |
| reported and not trusted to stop a build" β and `scan_source` now derives a hit's | |
| `advisory` flag from the hit's own grade as well as its rule's, which is the same | |
| derivation the corpus builder already does one level up. The query clause keeps the | |
| rule's `defect` and its 6 true positives, because a grade travels with the finding. | |
| Not deleted, and not by path. Skipping `*_demo` modules would be a heuristic invented to | |
| make a number smaller; grading a clause on what it was measured at is the thing this | |
| project does everywhere else. `touches-a-record-already-held` is 7 of the 23 and | |
| `scoped-to-a-record-already-held` an eighth of the same shape β a recordset the caller | |
| obtained under its own rules, then written under sudo. That is not nothing, because | |
| `ir.rule` governs write permission as well as visibility, but it is a different claim | |
| from the one the rule's `why` paragraph makes. Whether it is a second rule or a | |
| narrowing wants a tree where it fires on production code, and neither of these two is | |
| that tree. | |
| ### 3.48 A fourth sudo clause, taken from the missed list | |
| **Decided 2026-09-21.** The harvest is supposed to be a worklist, so it was used as one. | |
| `--harvest` over the OCA trees puts `.sudo(` at the top again, and one row in it is the | |
| miss the README had been naming by hand since Β§3.30: server-tools `cbefaa511485`, | |
| *"[FIX] excel_import_export: remove sudo() when importing record"*. The diff deletes one | |
| line. | |
| ```python | |
| self = self.sudo() | |
| ``` | |
| Nothing below it mentions sudo. Every clause in Β§3.47 looks for `.sudo().<method>()` or | |
| a sudo recordset handed to something, and this is neither: it **replaces the receiver | |
| the whole method is written against**, so each subsequent `self.env[...]` inherits the | |
| elevated environment while naming nothing. The commit says what it cost β *"finding | |
| multiple records across all companies during import when they have same record name. | |
| Previously, importing with sudo could bypass multi-company record rules"* β which is the | |
| rule's own `why` paragraph, written by somebody who had just been bitten by it. | |
| Three sites in 260,000 lines, so the clause is narrow as well as real. Run against that | |
| commit's own before and after images the finding sits on line 283, is gone afterwards, | |
| and 283 is one of the two lines the fix touched: an on-target catch by recall's own | |
| definition, which is why this clause carries no grade of its own and keeps the rule's | |
| `defect` where the touch clause takes `unproven`. | |
| The same commit **adds** `ModelData = self.sudo().env["ir.model.data"]`, a deliberate | |
| and narrowly scoped elevation. That is the evidence for not also flagging `.sudo().env[`, | |
| and it is worth stating: the author of the fix reached for the shape one clause would | |
| have reported, in the commit that fixed the thing another clause did report. | |
| ### 3.49 What the re-measurement says, and the grades that came out of it | |
| **Measured 2026-09-21**, over 795 fix commits across both ecosystems. | |
| | | OpenSPP before | after | OCA before | after | | |
| |---|---|---|---|---| | |
| | Fix commits examined | 406 | 409 | 385 | 386 | | |
| | β¦touching a covered construct | 160 | **173** | 56 | **64** | | |
| | Caught | 6 | **11** | 1 | **4** | | |
| | Recall against in-scope fixes | 4% | **6%** | 2% | **6%** | | |
| Both ecosystems land on the same number from different directions, which is worth more | |
| than either figure alone. The in-scope denominators rose too, so the improvement is not | |
| an artefact of measuring less. | |
| `.sudo(` remains the largest cluster on OpenSPP but a different shape: **67 missed, 9 | |
| caught, 52 spoke, 15 silent**, against 70/6/47/23 before. The silent column is what the | |
| new clauses ate. `noupdate` β 22 missed, 0 caught, 9 spoke, 13 silent β is now the | |
| cluster with the most silence against the least coverage, and is next. | |
| **Four rules earned a grade.** `recall --aim` placed every finding a checker made in a | |
| file a fix touched against the method the fix edited, and the placements decide: | |
| | rule | on-target | same-symbol | other | caught | grade | | |
| |---|---|---|---|---|---| | |
| | `sql-needs-query-parameters` | 3 | 5 | 12 | 1 | **defect** | | |
| | `naive-and-aware-datetimes-do-not-compare` | 2 | 5 | 16 | 2 | **defect** | | |
| | `check-company-on-relations` | 1 | 3 | 10 | 1 | **construct** | | |
| | `constrains-must-list-every-field-read` | 1 | 2 | 22 | 0 | **construct** | | |
| The two `construct` grades are the same reading `ondelete` and `stored-compute` already | |
| have: the rule recognises the declaration and is about the method rather than the line | |
| anybody fixes. | |
| ### 3.50 A third ecosystem, and the two things it changed | |
| **Measured 2026-09-21.** Every recall number this project had came from two trees, and | |
| the README's own caveat was that two repositories in one ecosystem is a narrow sample. | |
| So the pass was run again against **odoo core 18.0** β 559,663 lines, 190,217 commits, | |
| 4,368 `.sudo(` call sites β with the recall half capped at 400 fix commits. | |
| | | OpenSPP | OCA | odoo core | all three | | |
| |---|---|---|---|---| | |
| | Fix commits examined | 409 | 386 | 369 | **1,164** | | |
| | β¦touching a covered construct | 173 | 64 | 97 | **334** | | |
| | Caught | 11 | 4 | 5 | **20** | | |
| | Recall | 6% | 6% | 5% | **6%** | | |
| | Live findings per 1000 lines | 0.8 | 3.5 | **5.0** | β | | |
| Three ecosystems landing on the same recall figure from different directions is the | |
| first evidence this project has that ~6% is a property of the corpus rather than of a | |
| sample. The precision rate on core is the highest of the three and is **not classified**; | |
| core is the reference implementation, so a rate there is a statement about the rules | |
| rather than about somebody's house style, and reading those 2,784 findings is the | |
| largest single piece of work this repository now has outstanding. | |
| **It caught a checker defect that two trees could not.** `self = self.sudo(False)` | |
| turns record rules back **on**. It is how a method that must run under the caller's own | |
| rights says so, and odoo core does it 60 times β including mail's | |
| `_check_attachments_access`, whose docstring reads *"This method relies on access | |
| rules/rights and therefore it should not be called from a sudo env."* Every sudo clause | |
| read `.sudo` and ignored its argument, so the checker reported the safeguard as the | |
| defect. Neither OpenSPP nor OCA contains a single `sudo(False)`, so no amount of work on | |
| those two trees would have found it. `_escalates()` now reads the argument, and a | |
| non-literal argument still counts as an escalation because `sudo(flag)` can be either. | |
| **It stopped a grading mistake.** Measured on OpenSPP and OCA alone, eleven rules had | |
| fired nowhere at all, and `unproven` β "a real defect nothing measured here has met yet" | |
| β is literally accurate for a rule like that. Four of the eleven fire on odoo core: | |
| `bare-except-catches-the-exit` 28 times, `mutable-default-argument` 15, | |
| `eval-and-pickle-execute-their-input` 4 and `subprocess-shell-true-is-injection` 4. | |
| Grading off two trees would have quietly downgraded four working rules on the strength | |
| of not having looked far enough. | |
| The seven that fire nowhere across all three β 820,000 lines and 1,164 fix commits β | |
| are graded `unproven` on that evidence, which is more than the one rule already carrying | |
| the grade had. Two of the seven, `decimal-for-money-not-float` and `path-join-does-not- | |
| contain-a-path`, are silent because Β§3.47 narrowed them rather than because nothing was | |
| there; both readings are in the README and the grade records the second. | |
| Sixteen rules are now graded and eleven are `unmeasured`, which remains the accurate | |
| word for them rather than a placeholder. | |
| **One thing found, measured, and then not acted on.** Odoo core documents intentional | |
| elevation with its own comment convention β `# sudo: discuss.channel - guests don't | |
| have access for creating mail.message` β and uses it 136 times. The first reading was | |
| that this is the same artefact as a `# nosemgrep` and that recognising it would | |
| materially improve the rate on core. | |
| The second half of that is simply false, and counting says so: of the **538** sudo | |
| findings on core, **6** carry a `# sudo:` note within three lines. One percent. The 136 | |
| notes sit overwhelmingly on elevations no clause here fires on β the record-rule trigger | |
| has already dropped those, which is the trigger working. So the practical case for | |
| reading the convention evaporates, and what is left is only the argument of principle. | |
| That argument is worth writing down anyway, because it is the one that decides whether | |
| any future marker gets added. A `# nosemgrep` records that *an analyser made a specific | |
| claim and a human rejected that claim*. A `# sudo:` records that *the author, while | |
| writing the line, noted why they wanted it*. Those are different kinds of evidence: the | |
| first is a response to a challenge, the second is the intention of the person who chose | |
| the code. This corpus exists for defects that **look correct in review** β the headline | |
| measurement is that 17% of OpenSPP's fixes were silent until the data was visibly wrong | |
| β so an author's stated intention is close to the thing the tool is meant to second-guess, | |
| not evidence against the finding. Treating it as a suppression would also be the | |
| nexa-specific mute button Β§3.42 refuses to build, reached through a third party's | |
| comment style. | |
| The convention stays unread, and the reason is the principle rather than the number. | |
| There is a general rule in that, and it is worth stating because this file is otherwise | |
| an argument for measuring everything. A question that turns on **interpretation** does | |
| not become settled by being measured. The 1% above is a fact about two checkouts on one | |
| day; the thing actually in dispute is what a comment is evidence *of*, and no rerun | |
| answers that. Pinning a number to it would produce a reference that drifts on every tree | |
| and every rerun while the disagreement underneath it stays exactly where it was. Measure | |
| to check a claim already made β that is how the 1% corrected an overclaim in this very | |
| section β but do not manufacture a measurement in place of a decision. | |
| ### 3.51 Reading odoo core, and the first classification that is a sample | |
| **Measured 2026-09-21.** Β§3.50 left core's findings unclassified and called reading them | |
| the largest outstanding piece of work. Reading all 2,784 is not a session's work, and | |
| pretending otherwise would have produced a worse artifact than admitting it. | |
| So this is the first classification here that is a **sample rather than a census**: 15 | |
| findings of the largest rule β `stored-compute-needs-complete-depends`, 1,051 of the | |
| 2,784 β drawn with a fixed seed and read by hand. The draw is reproducible from the | |
| artifact's own description, without the script that made it. **11 true, 4 false**, which | |
| is 73% with a 95% Wilson interval of roughly 48% to 89%. The interval is wide and it is | |
| the honest number for n=15: it says the rule is not mostly noise on core and it does not | |
| say much more. | |
| **All four false verdicts have one cause.** `odoo/models.py` adds `display_name` to | |
| every model as `fields.Char(automatic=True, compute='_compute_display_name', | |
| search=...)` with **no store**. An override that omits a dependency therefore cannot | |
| leave a stale row, because there is no row. The checker read "no declaration in this | |
| file" as unknown storedness and kept the finding, which is the right reading for an | |
| ordinary field declared in another module and the wrong one here: the framework's own | |
| declaration is knowable and says not stored. 121 of the rule's 1,051 findings on core | |
| were this shape. | |
| The narrowing is free, and that was checked rather than assumed. Ten hand-verified true | |
| positives compute `display_name` β every one of them in OpenSPP β and every one declares | |
| `store=True` on it **in its own file**, so in-file storedness reads True and they are | |
| untouched. Re-run: 244 of 244 hand-classified true positives across all five artifacts | |
| still found, none lost. | |
| **The bigger thing the sample found, and did not act on.** 837 of odoo core's 1,360 | |
| stored computes are `readonly=False`: the "compute a smart default, then let the user own | |
| it" pattern, where a deliberately narrow `@api.depends` protects a user's edit from being | |
| clobbered. That is a plausible reading of most of this rule's volume on core, and it is | |
| not a safe narrowing. Three of the 47 resolvable hand-verified true positives on OCA and | |
| OpenSPP are themselves `readonly=False`, and nine of the eleven true verdicts in this | |
| very sample are too. The pattern is a consideration inside each verdict, not a rule, and | |
| the number that would have justified treating it as one is the number that refutes it. | |
| **What is still unread.** 1,612 live findings on core. The next three by volume are read | |
| in Β§3.52. | |
| ### 3.52 The other three rules on core, and what precision turned out to depend on | |
| **Measured 2026-09-21.** Same method as Β§3.51 and the same seed: 15 findings each of | |
| `sudo-bypasses-record-rules`, `assert-is-not-validation` and `ondelete-is-a-decision`, | |
| drawn and read by hand. With Β§3.51 that is 60 of core's findings across the four rules | |
| making 70% of them, in `artifacts/classification-odoo-core-2026-09.json`. | |
| | rule | core | previously | 95% Wilson | | |
| |---|---|---|---| | |
| | `ondelete-is-a-decision` | **13/15** | 12/12 OCA, 14/14 OpenSPP | 60β95% | | |
| | `stored-compute-needs-complete-depends` | **11/15** | 49/57 OCA | 48β89% | | |
| | `assert-is-not-validation` | **3/15** | 6/10 OCA | 7β45% | | |
| | `sudo-bypasses-record-rules` | **0/15** | 6 true on OpenSPP | 0β20% | | |
| Corrected 2026-09-27: the 49/57 in the `stored-compute-needs-complete-depends` row is | |
| OpenSPP's reading, not OCA's (`artifacts/classification-openspp-2026-09.json`: 49 true, 8 false, | |
| 3 undecidable). OCA's was 41 true of 41 decided when this was written, and 42 of 42 since | |
| Β§3.71. | |
| **The finding is that precision depends on what kind of code is under review, and this | |
| is the first tree here that made that legible.** Two rules hold up on core. Two collapse, | |
| and both collapse for the same reason rather than because the checkers are broken. | |
| `assert-is-not-validation`'s own true class, set when it was classified on OCA, is *the | |
| asserted value came from outside the module*. A framework's asserts are overwhelmingly | |
| invariants between its own components β the registry asserting its cache-name | |
| convention, a field descriptor asserting its own configuration, the query builder | |
| asserting an alias it generated itself. There is no outside. Twelve of the fifteen are | |
| that shape. | |
| `sudo-bypasses-record-rules` is 0 of 15 and every one is a deliberate framework | |
| elevation: a public route resolving an access token before any identity exists, an | |
| `@api.ondelete` guard that has to see records the caller cannot or the guard is | |
| bypassable, a cross-company sync whose entire purpose is to cross companies. The rule | |
| describes an application leaking between tenants. Core is the thing that implements | |
| tenancy. | |
| Neither is a reason to change a grade. A precision number is about a subject, and the | |
| subject here is one this corpus was never aimed at β nobody reviews odoo core with it. | |
| It is a reason to say so in the README, which is done, and it is the strongest argument | |
| yet that a single precision figure for "the corpus" means less than the same figure per | |
| kind of codebase. | |
| **Three checker defects, all found by reading rather than by testing.** | |
| `_auto = False` marks a model odoo builds no table for: a SQL view. There is no foreign | |
| key, so `ondelete` is not a decision anybody can make about it. Two of the fifteen | |
| ondelete findings were report models of exactly that kind, and the exemption joins | |
| TransientModel in `_transient_spans`. None of the 26 hand-verified true positives for | |
| this rule sits on such a model, so it is free. | |
| The other two are both `sudo` de-escalation the checker read as escalation, the same | |
| class as the `sudo(False)` defect in Β§3.50 and missed by the same reading. Odoo's | |
| signature is `sudo(self, flag=True)`, so **`sudo(flag=False)`** is de-escalation by | |
| keyword and `_escalates` was only reading positional arguments. And **`x.sudo(self.env.su)`** | |
| sets su to whatever the environment already has, so it cannot raise privilege above | |
| ambient; core writes it beside the comment *"Unsudo the invoice after creation if not | |
| already sudoed"*. `sudo(self.env.su or field.compute_sudo)` is deliberately not exempt, | |
| because the `or` can turn it on. | |
| Rates after all three: **4.7 per 1000 lines** on core, 0.8 on OpenSPP, 3.3 on OCA, with | |
| 244 of 244 hand-classified true positives still found. | |
| **One finding worth acting on outside this project.** 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 β but it is exactly the harm the rule | |
| describes, found in the reference implementation. | |
| **Still 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). | |
| ### 3.53 The documents are checked against the artifacts, not only the other way round | |
| **Measured 2026-09-26.** Every number in the sections above has an artifact behind it, | |
| and none of the sentences quoting them had anything checking that they still agreed. A | |
| rate was re-measured into a new paragraph while an older sentence kept stating the old | |
| one in the present tense. By this date the README gave OCA's rate as 3.0 in its | |
| introduction and 3.3 in its summary table, put "those 120 hits" beside a rate taken over | |
| 112, and gave recall as 1 of 56 and 7 of 162 in the table while the section below it said | |
| 20 of 334. Β§6 still opened on 2.9. No measurement was wrong. The prose had drifted from | |
| all of them, and in more than one direction at once. | |
| `bin/figures.py` recomputes every present-tense figure from the committed artifacts, and | |
| `--check` fails on any figure in README.md or ARCHITECTURE.md that disagrees. A figure is | |
| marked where it stands, `<!--fig:oca.rate-->3.0<!--/fig-->`, which renders as nothing. | |
| Dated sentences are not marked: "re-measured on 2026-09-16: 3.5" is a record of a past | |
| run and stays true after the next one, and a check that demanded it change would be | |
| demanding that history be rewritten. It runs in CI as its own job, needing no checkout | |
| and no model. | |
| Three things it needed that did not exist: | |
| - **The fieldtest artifacts were ten days stale** and carried no commit. Both were | |
| written before the checker changes of Β§3.47 to Β§3.52, and core had no artifact at all, | |
| so the 4.7 in Β§3.52 was the one headline rate with nothing behind it. All three are | |
| regenerated and now record the commit of each checkout and the time they were made. | |
| - **Recall had no artifact.** `recall --json` wrote bare rows with no head and no | |
| `--limit`, and nothing was committed. It now writes both beside the rows, and one file | |
| per ecosystem is committed. | |
| - **"244 of 244 still found" was asserted by hand** each time, by re-running against the | |
| checkouts. It is now computed from the artifacts: every `true` verdict in every | |
| classification is matched to a finding the committed fieldtest still reports. Across | |
| six classifications that is **271 of 271**. The 244 was five artifacts and predated | |
| core's sample. | |
| **Four of the 271 moved, and the window that allows it is printed rather than | |
| absorbed.** All four are `sudo` findings in OpenSPP read at the line of the `.sudo()` | |
| call; the AST pass now anchors them on the first line of the chained statement, one line | |
| up. A finding re-anchored within three lines is counted as found and listed as moved, so | |
| a window hiding a real loss would be visible in the output rather than in nobody's | |
| reading of it. | |
| **It also checks each artifact against itself.** A classification's `totals` and | |
| `by_rule` are a second statement of what its rows say, and one of them had drifted: | |
| `artifacts/classification-python-2026-09.json` summarised 26 true over rows holding 27. The 27th is | |
| the consent-receipt timestamp that a sudo marker was wrongly suppressing; its note said it | |
| was counted apart for that reason. Β§3.47 scoped markers to the rules they name, so it is | |
| reported now, and it is counted. The composed-in Python rules are 27 true of 51, 53%. | |
| A classification pinned to a different commit from the fieldtest it is matched against is | |
| a fault rather than something to match around. Line numbers from two commits say nothing | |
| about each other. | |
| ### 3.54 The recursion rule, and six true verdicts that were not | |
| **Measured 2026-09-26.** Reading `spp_area/models/area_core.py` behind a verdict marked | |
| true turned up a model the rule should never have fired on. `spp.area` sets | |
| `_parent_store = True`, and odoo's `_parent_store_update` raises *"Recursion Detected."* | |
| on any write that would make a record its own ancestor. It has done so since at least | |
| October 2019, so across every series the rule claims. The constraint the rule asks for | |
| is one the ORM already enforces. | |
| So every true verdict for `recursion-check-on-hierarchy` was re-read against the class | |
| around it. **Six of the seven were `_parent_store` models**: `account.asset.group` on | |
| OCA, and `spp.area`, `spp.area.type`, `spp.audit.rule`, `spp.consent.purpose` and | |
| `spp.hazard.category` on OpenSPP. All six are reversed to false, class | |
| `parent-store-refuses-the-cycle`, and each row keeps `was: true` so the reversal can be | |
| read rather than inferred. The seventh stands. `spp.dms.directory` declares a | |
| `parent_path` Char but never sets `_parent_store`, so nothing maintains it and nothing | |
| refuses a loop, while its `complete_name` and `root_directory_id` computes recurse up | |
| the chain. It is exactly the shape the rule is for. | |
| The rule was a regex for `parent_id = fields.Many2one`, cleared by either guard's name | |
| anywhere in the file. It could ask neither question the harm depends on, and both | |
| answers are in the class, so it moved to an AST pass, `parent_can_form_a_cycle`: | |
| - **Does the field point at its own model?** A `parent_id` to a different model is an | |
| ordinary foreign key. That was the `wrong-construct` false positive on OCA that Β§6 left | |
| waiting for this migration. Self-reference is read through a string, the class's own | |
| `_name` variable (`Many2one(_name, ...)`), and a module constant both are assigned | |
| from (`_name = _type_model`). | |
| - **Does the ORM already refuse the cycle?** `_parent_store = True` on `parent_id`, the | |
| default `_parent_name`, clears it. The same flag on some other chain does not. | |
| **Core found three more defects in the new checker before it shipped**, which is the | |
| reason core is measured at all. Its first run left 17 findings, and reading them showed | |
| eight that should not be there. Odoo's own test models write `_name = _description = | |
| 'x'`, and a helper reading only single-target assignments left those classes nameless, | |
| so a `parent_id` to a different model read as unresolvable and fired. Two are SQL views | |
| (`_auto = False`), which nobody writes and which the ondelete rule already exempts for | |
| the same reason. One is `res.partner`'s `parent_id = fields.Many2one(tracking=3)`, which | |
| names no model at all: it re-declares an attribute of a field declared in `base`, where | |
| the relation and its `_has_cycle` guard both live. All three are fixed and tested. | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 2 | **0** | | |
| | OpenSPP | 6 | **1** | | |
| | odoo core | 37 | **9** | | |
| The nine left on core are all self-referencing and unguarded: mail threads, report | |
| lines, restaurant tables, website menus, and five test models. None is classified yet. | |
| The rule stays graded `unmeasured`. `recall --aim` has never placed it, and eight read | |
| findings are too few to grade it by hand; they say the old checker was mostly wrong, and | |
| say little yet about the new one. The survival check now counts 265 hand-classified true | |
| positives rather than 271, because six of them never were, and all 265 are still found. | |
| ### 3.55 XML, which the tools had never read | |
| **Measured 2026-09-26.** `noupdate` was the harvest cluster with the most silence and | |
| the least coverage. The reason turned out to be structural rather than a missing rule: | |
| `noupdate-on-user-editable-data` checks XML data files, and `fieldtest` and `recall` | |
| read `.py` only. The rule had never been fired at a line of real code. Its `unproven` | |
| grade was a statement about where the tools looked. | |
| Pointing `nexa check` at the incident behind it showed three more things wrong. | |
| OpenSPP's `124a9d822f` (#411) fixed a registry lock that every upgrade re-applied, | |
| because the setting's install default lived in `config_parameters.xml` with no | |
| `noupdate`. Before that fix, and after it: | |
| - **Every XML file was parsed as Python.** It failed at line 1, and the report said | |
| "15 semantic checkers never ran" and exited "not fully checked", on a valid file. It | |
| also said the file was "reviewed against the 8 plain-Python ones". | |
| - **The rule fired once, on `<odoo>`**, for any record at all. Views, menus and actions | |
| have to upgrade; advising `noupdate` on them is advice nobody should take. | |
| - **One `noupdate` anywhere cleared the whole file.** After the fix the file still holds | |
| a parameter outside the block, and the regex called it clean. | |
| **XML is a file kind now.** A rule declares `applies_to: xml`, `scan_source` reads the | |
| kind off the content and runs only the rules that apply to it, and a file expat cannot | |
| read is a gap with its line rather than a Python syntax error. `fieldtest` reads XML and | |
| counts it apart: every per-kloc rate published before this is a rate over Python lines, | |
| and folding view definitions into that denominator would have made the next number | |
| incomparable with all of them. Not one Python figure moved. `recall --xml` is a separate | |
| measurement over XML fix commits, so the Python denominator it shares with | |
| `surfaced.py` is untouched. Demo files are skipped by both, because Odoo loads them | |
| with `noupdate` forced on. A data file named `test_config.xml` was being skipped as a | |
| test by its name, which is right for Python and wrong for XML a manifest lists under | |
| `data`. | |
| **Which records, measured rather than chosen.** The checker is an expat pass that | |
| reports each `<record>` no `noupdate="1"` reaches, at its own line, for the models an | |
| administrator tunes. The list came from asking odoo core what it does with its own | |
| records: `ir.config_parameter` 26 of 28 under `noupdate`, `ir.sequence` 16 of 17, | |
| `decimal.precision` 7 of 8, stages 30 of 34 across seven stage models, against 0 to 2% | |
| for views, actions and reports. `ir.rule` is left out although core protects 566 of 598, | |
| because a frozen record rule never receives the security fix its module ships later, | |
| and that trade is not a checker's to make. | |
| **Crons came in on an incident and were narrowed on the reading.** Core protects only | |
| 39 of its 93, so the measurement did not support them; OpenSPP's #473 did, naming two | |
| crons and two parameters whose tuned values every upgrade reset. Every XML finding on | |
| the three trees was then read, 76 of them, into `artifacts/classification-xml-2026-09.json`: | |
| | | true | false | undecidable | | |
| |---|---|---|---| | |
| | parameters, sequences, stages | **11** | 3 | 0 | | |
| | crons | 3 | **57** | 2 | | |
| A cron's schedule is usually the mechanism, not a setting: the midnight rate-limit | |
| reset, the hourly expiry check, the 2 a.m. posting run. An upgrade restoring those | |
| restores intended behaviour. The shape that held up **ships `active=False` for an | |
| operator to switch on**, which is exactly the decision an upgrade then undoes, so crons | |
| now count only in that shape. That gives up the two quarantine crons #473 names; their | |
| rows say so in `given_up`, and `figures.py` counts them apart from the survival check | |
| instead of letting the check fail on them or hiding them from it. Their retention | |
| parameters, which carry the harm #473 describes, are still reported. On core the 59 XML | |
| findings become 7. | |
| Two findings stand out. `spp_programs`' `program_fund_ref_num` is a `no_gap` sequence | |
| that sets `number_next` to 1 in its XML, and `ir.sequence.write` stores that directly for | |
| `no_gap`: every upgrade of the module restarts fund reference numbers at 1. And core's | |
| `base.template_portal_user_id` is a Settings field in `auth_signup`, reset by every | |
| upgrade of `base`. The two core crons that ship disabled and are switched on at runtime, | |
| payment post-processing and predictive lead scoring, are recorded undecidable: an | |
| upgrade resets them, and whether anything re-enables them afterwards needs a running | |
| database to say. | |
| **Recall over XML.** 197 fix commits on OpenSPP, 45 on OCA, and core capped as before. | |
| The OpenSPP catches are #411 itself, the incident this section started from, and one | |
| coincidence: `45c82bb0b701` deleted a parameter holding a shipped secret, and the | |
| finding went with the record rather than with a `noupdate`. It is reported as both | |
| caught and coincidental, because the criterion counts it and a reader should not. The | |
| fix that added `noupdate` to the #473 file, #479, landed after the commit these | |
| measurements are pinned to, so the checker reports those records as live, which is | |
| what it should do. | |
| The rule stays advisory and `unproven`. One incident caught is the first evidence it has | |
| had, and it is not a grade. | |
| ### 3.56 Odoo runs in UTC, and the rule that carried the Python half did not know | |
| **Measured 2026-09-26.** `naive-and-aware-datetimes-do-not-compare` was the one | |
| composed-in rule that justified the composition: 21 of its true positives, graded | |
| `defect`, and the README said so under "One rule carries it". Every true verdict but three | |
| rested on a single premise, written into the rule's own comment: an Odoo `Datetime` is | |
| naive UTC, so a naive *local* `datetime.now()` reaching one is wrong by the server's | |
| offset, latent on a UTC server and live on any other. | |
| Sampling it on core, the same way as Β§3.51 (`random.Random('20260921:' + rule_id)`), | |
| found the premise false inside Odoo. `import odoo` runs | |
| `odoo/_monkeypatches.set_timezone_utc`, which sets `TZ=UTC` and calls `time.tzset()`; | |
| OpenERP did the same in its `__init__` for the same reason. And | |
| `fields.Datetime.now()` is `datetime.now().replace(microsecond=0)`. Inside an Odoo process | |
| there is no server offset for `datetime.now()` to be wrong by. Core's sample is **0 of | |
| 15**: every finding stores or compares UTC against UTC, or takes an epoch timestamp, | |
| which does not depend on the zone at all. | |
| Re-read under a criterion that does not depend on the server's clock, the OpenSPP and | |
| OCA verdicts are **3 true of 33**, from 21. Eighteen are reversed, each keeping | |
| `was: true`: | |
| | class | | | | |
| |---|---|---| | |
| | `utc-by-odoo` | 15 | a "local" now against or into ORM values, a `Z` on a time that is UTC, a JWT whose library reads naive as UTC | | |
| | `default-never-reached` | 2 | a server-date default after the required-field check that rejects its absence | | |
| | `created-a-day-ahead` | 1 | a fiscal-year cron a whole day early, further than any offset | | |
| The three that stand are two defect shapes. **A naive UTC timestamp leaving the system** | |
| with no offset: two consent API responses, and exactly what the two commits recall | |
| credits this rule with fixed, by writing `datetime.now(UTC)`. And **the server's date used | |
| as a company's calendar day**: OCA's `purchase_unreconciled` dates a write-off move with | |
| it, so for a company ahead of UTC the early hours land on the day before, at month end in | |
| the period before. OpenSPP's own birthdate fixes, #397 and #520, are that class too, | |
| which is where the old comment's "two fixes of exactly this class" came from; they were | |
| never about the server's offset. | |
| **The rule's advice was wrong for Odoo, not only its grade.** "Never call | |
| `datetime.now()` without a tzinfo" applied to an ORM value raises: `Datetime.to_datetime` | |
| refuses an aware datetime with `ValueError`. The rule now says where aware belongs, at | |
| the edges, and names `fields.Date.context_today` for a user's day. Its grade moves from | |
| `defect` to `preference`, which gates nothing: inside the ORM the finding is the house | |
| spelling of the same value. The checker is unchanged and still fires on every | |
| `datetime.now()`; a narrowing to the two surviving shapes needs to see where the value | |
| goes next, which the regex cannot, and it is on the worklist. | |
| The composed-in Python rules go from 27 true of 51 to 9 of 51. Β§6 said the two halves of | |
| the corpus were not the same quality, and the distance is larger than it said. | |
| ### 3.57 The other two core samples | |
| **Measured 2026-09-26.** Same seed, same recipe, the two rules left from Β§3.52's list. | |
| | rule | core | OpenSPP | OCA | | |
| |---|---|---|---| | |
| | `constrains-must-list-every-field-read` | **10/15** | 38/45 | 7/7 | | |
| | `depends-must-not-name-unread-fields` | **5/15** | 10/11 | 3/3 | | |
| `constrains` holds up on the framework as it does on applications, which is what a | |
| rule about a mechanism rather than a use case should do. Its five false ones are three | |
| fields `ir.model` refuses to change after creation, one read that only phrases the error, | |
| and a rate that is only ever bad through a direct write, which fires the check. One true | |
| positive is a defect in core rather than a gap: `ir.actions.server._check_sms_template_model` | |
| declares `template_id` and reads `sms_template_id`, so the field it validates is the one | |
| whose writes never trigger it. | |
| `depends` does not, and its ten false ones are three shapes, each a predicate the checker | |
| could learn from evidence already in the file: | |
| - **The override calls `super()` (3).** Redefining `@api.depends` on an override replaces | |
| the parent's dependencies, so an override has to name what the chain reads, and its own | |
| body reading nothing is correct. This one is safe to exempt outright. | |
| - **A base stub declared for its overrides (2).** The body assigns a constant; modules | |
| that override it read the declared fields and inherit this decorator. | |
| - **A value meant to reset when its dependency changes (5).** `scrap_qty` back to 1 when | |
| the product changes, a trigger cleared when the automation's model changes. The | |
| dependency is the behaviour. This is the hard one: it is not distinguishable from a | |
| leftover without knowing the intent, and the two OpenSPP fixes the rule was harvested | |
| from were leftovers. | |
| The survival check counts 274 hand-classified true positives across seven | |
| classifications, all still reported. | |
| ### 3.58 The last three named false-positive classes | |
| **Measured 2026-09-26.** The worklist named three classes left over from Β§3.43. Reading | |
| what was still live showed one was already closed and two had shapes nobody had written | |
| down. | |
| **`check-company-on-relations`: one left, a SQL view.** The `related=` mirrors and the | |
| computed `Many2many` the worklist named had shipped in Β§3.43. The one live false positive | |
| was OpenSPP's `fund_report`, `_auto = False`: no table, so no foreign key to guard. The | |
| ondelete and stored-compute checkers already exempted views; this one now does too. | |
| **`sudo-bypasses-record-rules` on registry models: measured, then named.** Three | |
| `sudo().search()` calls on `ir.model` stayed live on OCA after the AST move, because the | |
| rules that would say whether `ir.model` is filtered live in `base`, which the tree does | |
| not contain, so the trigger read UNKNOWN and kept them. Asked directly, odoo core gives | |
| record rules to five framework models only: `ir.default`, `ir.embedded.actions`, | |
| `ir.filters`, `ir.ui.view` and `ir.ui.view.custom`. `ir.model`, `ir.model.fields`, | |
| `ir.model.data` and `ir.module.module` have none, so `sudo()` on them drops nothing, and | |
| they are exempt by name. `ir.attachment` has none either and is kept out of that list: | |
| its own `_search` filters by access unless the environment is su, so `sudo()` drops a | |
| real check under another name. That only holds where the tree cannot see `base`. On | |
| core, the tree's own facts show `ir.attachment` has no record rule, and the existing | |
| trigger drops the finding, because this rule is about record rules and that access check | |
| is outside its scope. The gap is the rule's scope, and it is stated here rather than | |
| closed by a special case. | |
| The checker also learned to read `self` as the enclosing class's model, which is how one | |
| of the three was spelled. On OCA that took two more unclassified findings, both searches | |
| on `ir.model` and `ir.model.fields`. On core it took 26, and every one was read. Twenty-five | |
| are models the tree shows unruled, or calls that re-apply the model's own rule: a | |
| `sudo()` search over ids a plain search had already filtered, wishlists filtered by the | |
| user's own partner, a company's own branches, sequence lookups inside one journal. | |
| **One is not, and the reason predates this pass.** `website_sale`'s | |
| `set_sequence_up` swaps website sequence with a neighbouring `product.template` under | |
| `sudo()`, filtered by `website_published`. `product.template` carries two rules, a | |
| company rule and a website visibility rule on `website_published`, and the re-scoping | |
| test counts a call as re-applying the rule when its arguments name any one rule field. | |
| Naming one of two rules is not re-applying both. Resolving `self` made this call | |
| reachable by that weakness; before, the model was unknown and the finding stayed. It is | |
| recorded on the worklist rather than fixed here: the fix is to require every rule's | |
| fields, and that changes the predicate every sudo verdict so far was read against. | |
| The fourth OCA `sudo` false positive is the rebind clause, `self = self.sudo()`, over a | |
| method that then only reads registry models. Proving that means showing every call after | |
| the rebind is safe, which is a different checker; it stays classified false. | |
| **Read only to phrase a message: two spellings missing.** The exemption knew a raise and | |
| a message assigned to a variable. OpenSPP's five live false positives of this class were | |
| all a **log line**, `_logger.warning("... %s", rec.name)`: a value read to phrase | |
| something decides nothing, whether it is raised or logged. Core's one was a message | |
| **collected in a list** and raised joined. Both spellings are now exempt; a field read in | |
| the condition as well is still reported, and a test says so. | |
| On the trees: OCA 103 to 98 live findings, OpenSPP 154 to 148. Every finding that went is | |
| listed in the commit and was read. All 274 hand-classified true positives are still | |
| reported. | |
| ### 3.59 An override that calls super(), and a reason that was backwards | |
| **Measured 2026-09-26.** Β§3.57 named three shapes behind `depends-must-not-name-unread-fields`' | |
| false positives on core and called the first safe to exempt outright: a compute whose body | |
| calls `super()`. It is now exempt. | |
| The reason Β§3.57 gave was wrong, and reading the framework rather than the finding showed | |
| it. It said redefining `@api.depends` on an override *replaces* the parent's dependencies. | |
| It does not. `Field.get_depends` resolves the compute through the MRO and extends one list | |
| with every override's `_depends` (`odoo/fields.py`, 18.0 at 3c3e3b3), so an override's | |
| decorator **adds** triggers for the chain, and the fields it names are read by the parent's | |
| body, which the override reaches through `super()`. Core says so in its own comments: | |
| *"EXTENDS 'web' to add dependencies"*, *"We are just adding depends on this compute."* The | |
| exemption stands on the right reason now, and the docstring and test say that one. | |
| The second shape, a body that only assigns a constant, was tried and withdrawn in the same | |
| pass. It lost three true positives on OpenSPP and OCA, leftovers read true by hand: a | |
| "legacy method", and a target model that is always `res.partner`. On core the same shape was | |
| a stub for overrides or a deliberate reset. The file cannot tell intent apart, so it stays a | |
| finding, and a test pins that it does. | |
| On the trees: OCA and OpenSPP unchanged, and core **2,420 to 2,380**. All 40 findings that went | |
| were read, and every one is an override whose body delegates to `super()`, most with nothing | |
| else in it: the partner and employee avatars, the `need_cancel_request` computes of four | |
| country modules, the currency-rate computes that Czech and Polish invoices re-base on the | |
| taxable supply date. Three of them were in core's read sample, so the sample is 79 now, 42 | |
| true and 37 false. All 274 hand-classified true positives are still reported. | |
| ### 3.60 Re-scoping has to re-apply every rule everybody gets | |
| **Measured 2026-09-26.** Β§3.58 left one weakness recorded rather than fixed: a `sudo()` call | |
| counted as re-scoped when its arguments named a field of *any one* of its model's record | |
| rules. Odoo does not combine rules that way. Global rules, the ones with no `groups`, are | |
| ANDed for every user; group rules are ORed per audience. Naming a group rule's field | |
| re-applies that rule for that audience and nothing else. The tree facts now keep each rule | |
| apart with its audience, and a call is re-scoped only when it names a field of **every global | |
| rule** on its model. Where a model has no global rule the old test stands, any rule's field | |
| and then `company_id`, so nothing about group-only models moved. | |
| It is the first change to this checker that can only add findings, so the reading was of | |
| what it added, not of what it removed. OCA: nothing. OpenSPP: 19, every one on a line | |
| OpenSPP's own `nosemgrep: odoo-sudo-without-context` marker already acknowledges, so all 19 | |
| are decided and out of every rate, as decided findings always have been. Core: **45**, read | |
| in full into `artifacts/classification-sudo-core-2026-09.json`, not sampled. | |
| **One is true, and it is the one the change was made for.** `website_sale`'s `set_sequence_up` | |
| finds its neighbour under `sudo()` among every company's published templates and writes that | |
| template's `website_sequence`, so reordering one company's shop reorders another's. The file | |
| argues the case itself: `set_sequence_down`, six lines below, is the same code with a plain | |
| `search()`, which the company rule filters. | |
| **The other 44 were exempt before for a reason that was not the reason.** Each named a | |
| field some *group* rule follows, `employee_id`, `move_type`, `website_published`, and for | |
| `res.partner` the portal rule's `id`, so `search([('id', '=', partner_id)])` passed as re-scoped | |
| on the strength of a rule that only portal users get. Most of them are fine anyway, for | |
| reasons the checker cannot see: | |
| | class | | | | |
| |---|---|---| | |
| | `domain-scopes-it-already` | 18 | confined through a record the caller holds: one journal, `self.ids`, a leave's employee, the user's own partner | | |
| | `public-website-read` | 14 | the `/partners` and `/customers` pages, a published combo, a forum redirect | | |
| | `guard-must-see-everything` | 5 | a constraint or lock that has to see every company to be a guard at all | | |
| | `posts-in-the-records-own-journal` | 3 | an entry created in the journal of the order or valuation it is for | | |
| | `touches-a-record-already-held` | 2 | a write on records a plain search just found | | |
| | `company-refiltered-outside-the-call`, `refiltered-by-the-callers-search` | 2 | the company check happens two lines later, or in the caller's own search | | |
| So the change trades 44 false positives on core, 5 of them advisory, for a test that says | |
| what it means and one true positive the old test hid. Core goes from 2,380 to **2,420** live | |
| findings; OpenSPP and OCA are unchanged in every rate. That is not obviously a good trade on | |
| precision alone, and it is recorded as a measured cost rather than presented as a win. What | |
| would recover most of it is the largest class, and it is one predicate: a domain leaf whose | |
| value is the ids of a recordset the caller obtained under its own rules. That exemption would | |
| restore what the old test let through on a stated reason instead of an accidental one. It is | |
| on the worklist rather than built here, because it changes OpenSPP and OCA findings as well, | |
| and the six OpenSPP true positives have to be re-read against it first. | |
| No existing verdict changed. A stricter test cannot remove a finding, so every `sudo` verdict | |
| read against the old test still sits on a finding, and none of the 45 had been read before. | |
| The survival check now counts 275 true positives across eight classifications, all reported. | |
| ### 3.61 The datetime rule sees where the value goes | |
| **Measured 2026-09-26.** Β§3.56 left `naive-and-aware-datetimes-do-not-compare` graded | |
| `preference` with a regex that fired on every `datetime.now()` and `utcnow()`, and named the | |
| two shapes that survived reading. Both need to know where the value goes next, so the rule is | |
| an AST pass now, `naive_time_leaves_or_dates_a_day`: | |
| - **A naive timestamp leaving the system with no offset.** Its `isoformat()` anywhere, unless | |
| a `Z` or `+00` is written after it by hand; or the value itself as a keyword of a class being | |
| built, or in a returned dict, in a file that imports fastapi, pydantic, starlette or | |
| `odoo.http`. | |
| - **The server's UTC date used as a calendar day.** A `.date()` of it into a vals key or | |
| domain field spelled as a date, a `Date` field's default, or an attribute named for a date; | |
| and a naive `now()` into a key named exactly `date`, which Odoo truncates to UTC's day. | |
| The sources did not change. `date.today()` and `fields.Date.today()` are the same UTC date and | |
| were measured rather than added: 48 and 114 sites on OpenSPP, 31 and 217 on core. Adding them | |
| would have turned a narrowing into a widening, and `fields.Date.today()` is the framework's own | |
| spelling of the value. | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 7 | **1** | | |
| | OpenSPP | 25 | **5** | | |
| | odoo core | 167 | **2** | | |
| All three hand-classified true positives are still reported, and both commits recall credits | |
| the rule with still fire on the files as they were before the fix. On OpenSPP and OCA every | |
| finding that went had been read false. On core the 167 were too many to read one by one, so | |
| they were triaged: the 84 in a file that speaks HTTP, or with `.date()`, `isoformat`, | |
| `strftime` or a date key within three lines, were read, and the rest are the `utc-by-odoo` | |
| shape the sample already named, a naive UTC time meeting naive UTC values inside the ORM. | |
| **The reading of what went is what found the two on core, and they changed the checker.** The | |
| first draft gated shape A on HTTP alone, and it removed `l10n_es_edi_facturae` and | |
| `l10n_es_edi_tbai` writing `datetime.now().isoformat()` into `xades:SigningTime`, an | |
| `xsd:dateTime` in a signed e-invoice. Inside Odoo the value is UTC; with no offset, a reader | |
| takes it as local time, and in Spain that is one or two hours off. It is exactly the rule's | |
| sentence, "a file for another system", so `isoformat()` became a clause of its own. It costs | |
| three findings OpenSPP's classification already reads false, a generated-at line in two | |
| report scripts and a `computed_at` in a result dict, and that trade is kept in the open rather | |
| than tuned away. Both core findings are recorded in `artifacts/classification-datetime-core-2026-09.json`, | |
| a census rather than a sample. | |
| What it misses, and says so: `l10n_in`'s demo invoices write `'invoice_date': datetime.now()` | |
| into a `Date` field. A key ending in `_date` can just as well be a `Datetime` (stock's | |
| `scheduled_date`), so a naive time into one is not reported and its `.date()` is. | |
| **The grade goes back to `defect`**, the promotion Β§3.56 said should follow once the checker | |
| could see the shape. On the three trees it is 5 of 8 read true, and both recall catches are | |
| on-target. Those are small numbers and are stated as small. The rule's `wrong` sample moved | |
| with it: it was a comparison, the one shape that did not survive, and a checker has to fire on | |
| its own wrong sample. The composed-in Python rules gain nothing and lose nothing in their | |
| classification: the verdicts are unchanged, only which of them are still reported. | |
| ### 3.62 `_sql_constraints` is ignored from Odoo 19 | |
| **Measured 2026-09-26.** The rule an incident asked for, and the first new rule since the | |
| corpus reached 27. OpenSPP's 208d9758 (#394) found `spp.cr.type.reason.document` declaring | |
| its `(cr_type_id, reason)` uniqueness in `_sql_constraints` on a 19.0 module. Odoo 19 logs | |
| one warning at load and creates nothing, so duplicate rules were saved from 19.0.3.0.0 | |
| until the fix, and the fix added a lint of its own because nothing in CI had caught it. | |
| `odoo.sql-constraints-are-ignored-from-19` is an AST pass over the file plus the manifest's | |
| series, `legacy_sql_constraints_on_19`. | |
| **A version claim was wrong on the way in.** Β§3.45 and the `prefer-sql-constraint` rule said | |
| `models.Constraint` arrived in Odoo 17. Odoo 18.0 core at 3c3e3b3 has no `models.Constraint` | |
| anywhere and declares `_sql_constraints` in 174 files; the 18.0 ORM reference has no | |
| "Constraints and indexes" section and the 19.0 one does, declaring `Constraint`, `Index` and | |
| `UniqueIndex` and never naming `_sql_constraints`. So up to 18 the attribute is the only | |
| spelling, and from 19 it does nothing. The rule text, both correct samples that said "Odoo 16 | |
| only", and the checker docstring that repeated it now say 16 to 18. Β§3.45 is left as it was | |
| written; this is its correction. | |
| **Which series decides everything, and there are three answers.** 19 or later: reported, at | |
| the assignment. 16 to 18: silent, because a finding would be advice that cannot be taken. | |
| Unknown: silent when a tree was walked and simply does not pin a series, which is odoo core, | |
| whose manifests carry versions like "1.0" and would otherwise yield 174 findings, every one | |
| wrong. Reported as advisory, `unproven` on the finding itself, when nothing was walked at | |
| all, because "nobody looked" and "looked and it does not say" are different answers, and | |
| silence on the first would hide the rule from a bare snippet and from `validate`'s own samples. | |
| It is the first rule to declare `odoo_versions` other than `>=16.0`, and that exercised the | |
| context layer's per-rule check for the first time: grounding a 17.0 module with it is now a | |
| finding, which is what that check was written to say. A test pins both sides. | |
| On the trees it fires nowhere, and that is the expected answer rather than a missing one. | |
| OpenSPP at the pinned commit already contains the fix and has 72 files of `models.Constraint`; | |
| OCA's 20 `_sql_constraints` are all in 18.0 modules; core is 18.0. So the evidence is | |
| OpenSPP's history. Five blocks were written on 19. Three were fixed together by `ca44b5e3`, | |
| *"migrate _sql_constraints to models.Constraint (Odoo 19)"*, one is #394, and one went with a | |
| reverted feature rather than a fix. The checker fires on all four fixed ones as the files stood | |
| before their fix and on none after, and `nexa check --no-model` on the #394 file inside its | |
| 19.0 module reports it as high impact, likelihood defect, at the line the fix removed. | |
| It is graded **defect** on that, which is more than `noupdate` got for one incident in Β§3.55, | |
| and the difference is the construct rather than the count. A heuristic can be wrong about | |
| intent. This cannot: on 19 the attribute does nothing, so every finding is a constraint that | |
| does not exist. The one way to be wrong is a series misread, and that is why the unknown | |
| series is handled the way it is. | |
| Its probe, *"after upgrading to Odoo 19 my unique constraint is not enforced and duplicates | |
| are saved"*, brings it back inside the top 3. Re-measured 2026-09-27 with the index rebuilt: | |
| all 28 rules retrievable at `TOP_K` 6, and 26 of 28 in the top 3, where it had been 25 of 27. | |
| The two that miss at 3 are `commit-inside-a-loop` and `eval-and-pickle`, not this one. | |
| ### 3.63 A named bug above the fold | |
| **Checked 2026-09-27.** A maintainer reads the first screen, and the first screen described | |
| a class of defect without showing one. It now shows one: OpenSPP's `spp.area` stores | |
| `area_level`, computes it from `parent_id.area_level`, and declares only `parent_id`, so moving | |
| an area recomputes its own level and leaves every area beneath it on the old one. It was | |
| classified true in `artifacts/classification-openspp-2026-09.json` and is re-checked rather than | |
| remembered: still line 84 at cfa0dc8e, still reported by `nexa check --no-model`, and | |
| `OpenSPPArea.write` recomputes nothing by hand. `spp_api_v2_gis` groups areas by the level, | |
| which is where the stale value would show. | |
| The second candidate the worklist named, `spp_approval`'s `_check_approval_config`, is live | |
| too and stays out of the opening: it is a constraint that `use_multitier` alone skips, a | |
| real gap but a longer story to tell in four lines. One example says what the tool is for; | |
| two start to read like a list. | |
| ### 3.64 A domain on records the caller already holds | |
| **Measured 2026-09-27.** Β§3.60 made `sudo` re-scoping honest and paid for it with 44 false | |
| positives on core, and named the largest class as one predicate. It is built now: | |
| `_tied_to_held_records`. A `sudo()` call counts as re-scoped when a domain leaf pins it to the | |
| ids of records the caller obtained under its own rules, with `=`, `in` or `child_of`. | |
| `('employee_id', 'in', self.ids)` returns the contracts of employees the caller can already | |
| see; `('journal_id', '=', self.journal_id.id)` returns the moves of one journal, which belongs | |
| to one company. | |
| "Held" is narrow on purpose, because every yes suppresses a finding. `self`, reached through | |
| attributes and calls that return a subset of what the caller could see (`filtered`, `mapped`, | |
| `sorted`, `with_context`, `exists`, a plain `search`); the user's own records; a name every | |
| assignment to which is one of those; a loop variable over one. Not held: an id the caller | |
| passed in, anything under `sudo()`, `browse(ids)` and `env.ref(...)`, which apply no rule, and | |
| the result of any other method on `self`. That last exclusion came from reading: the first | |
| draft trusted a controller's `self._get_search_countries()` because its root was `self`, and | |
| said nothing about whose rules produced the countries. Five findings came back when it was | |
| tightened. `!=`, `<` and `not in` never count, which is why `set_sequence_up`, whose leaf is | |
| `('website_sequence', '<', self.website_sequence)`, is still reported. | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 99 | 99 | | |
| | OpenSPP | 153 | 153 | | |
| | odoo core | 2,422 | **2,350** | | |
| Of the 45 that Β§3.60 added, 14 go: 11 of the 18 `domain-scopes-it-already` and 3 guards. The | |
| other seven of that class stay, each for a reason the predicate gives: four take a task, | |
| partner or commercial-partner id as a parameter, one builds its ids in a list appended to | |
| in a loop, the recruitment check is scoped by website rather than by records, and a channel | |
| list is assigned once from the user and once under `sudo()`. The one true positive stays. | |
| It also reached 58 older findings nobody had read, and every one was read before accepting | |
| it. None is a defect. 20 are guards that must see every company: an account with journal | |
| items anywhere cannot be deleted, a product used in any stock move cannot change its unit. 21 | |
| are records belonging to held records: a leave's work entries, a record's own activities and | |
| scheduled messages, a message's notifications. 9 are the user's own: their loyalty card, | |
| their slide progress, their departments. 2 are deliberate syncs across companies of one | |
| shared record: a global milestone setting applied to every company's lines, and a partner's | |
| new address re-pricing that partner's draft carts everywhere. | |
| The risk named before building it held up and is worth keeping in view. Partners and | |
| products are shared across companies, so a search tied to `self.partner_id` can still cross | |
| a company boundary. In what was read, every such call was a guard, a sync of the same person | |
| or product, or a count, and none returned another company's records to someone who could not | |
| see them. That is a statement about core, not a proof, and a tree where it is false would show | |
| up as a lost true positive in the survival check. All 277 are still reported. | |
| ### 3.65 A public page reading what it publishes | |
| **Measured 2026-09-27.** The second class Β§3.60 named, `public-website-read`, 14 of the 44, | |
| was mostly one shape. `website_customer`'s `/customers` and `website_crm_partner_assign`'s | |
| `/partners` list, group and count partners under `sudo()`, because the anonymous visitor's | |
| own rules would hide most of them, and every domain they build starts from | |
| `('website_published', '=', True)`. That is `_published_on_a_public_page`. | |
| The reason is in the framework, not in the page. Publishing is the record owner's decision to | |
| show it to everybody. And on a model with `website.published.multi.mixin`, which | |
| `res.partner` and `product.template` both carry, `website_published` is a computed field whose | |
| `_search_website_published` adds the current website's domain whenever the request has one | |
| (`website/models/mixins.py`, 18.0 at 3c3e3b3). A `website=True` route always has one. So the | |
| leaf re-applies the scoping a public visitor's page is meant to have, in a spelling the rule | |
| field test cannot read. | |
| Narrow on purpose, as Β§3.64 was, because every yes suppresses a finding. All of these must hold: | |
| - a read (`search`, `search_count`, `search_read`, `read_group`), not a write; | |
| - inside a function decorated `route(..., auth='public', website=True)`; | |
| - the literal leaf `('website_published', '=', True)` ANDed at the top of the domain. The | |
| domain is read as the prefix notation it is: a leaf under `|` or `!` does not hold for | |
| every record returned, a list glued on with an operator short of terms is a fragment, and | |
| an `insert` can put an operator in front of the leaf, so each of those keeps the finding. | |
| A domain built in a name counts only when every plain assignment to it carries the leaf; | |
| `+=` and `append` only AND terms on. | |
| `is_published` does not count: it applies no website. Neither does a published flag compared | |
| with anything but `True`, nor the same search in a model method, which is how | |
| `set_sequence_up` stays reported. | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 99 | 99 | | |
| | OpenSPP | 153 | 153 | | |
| | odoo core | 2,350 | **2,336** | | |
| Of the 14, the 12 on `/customers` and `/partners` go. The other two stay, each for a reason the | |
| predicate gives: the forum redirect looks a partner up by an id from the URL, and the combo | |
| configurator's search runs in a helper, not in the route, with no published leaf of its own. | |
| Two older findings also went, in `website_membership`'s `/members` page: a `read_group` of | |
| published members by country and a search for published free members. Both were read; both | |
| are the same shape as the twelve. The dotted `('partner.website_published', '=', True)` on the | |
| membership lines three calls away is not the leaf this reads, and those stay. | |
| The cost is named, as Β§3.64 named its own. A partner published with no `website_id` is | |
| published on every website, so a company's `/partners` page can list another company's | |
| published reseller. That is Odoo's design for a record published everywhere, and the owner | |
| chose it; it is still a company boundary the page crosses, and a tree that means otherwise | |
| would show up as a lost true positive in the survival check. All 277 are still reported. | |
| ### 3.66 A mapped() path is a read | |
| **Measured 2026-09-27.** Building the sample findings report for the Odoo review, the depends | |
| check was silent on this: | |
| ```python | |
| @api.depends("invoice_ids") | |
| def _compute_outstanding_total(self): | |
| for account in self: | |
| account.outstanding_total = sum(account.invoice_ids.mapped("amount_residual")) | |
| ``` | |
| and reported the same read written as `sum(inv.amount_residual for inv in account.invoice_ids)`. | |
| `_field_reads` collects attribute accesses, and the only one in the first spelling is | |
| `invoice_ids`, which is declared. The field that goes stale is a string argument. | |
| `_mapped_reads` now reads `x.mapped("a.b")` with a literal string as a read of `x.a.b`. The | |
| receiver resolves the way a loop's iterable does, through `filtered()`, `sorted()` and `sudo()`, | |
| and everything after that is the checker as it was: a trailing `.id` names the relation, a | |
| field the method computes is never asked for, a non-stored compute is outside the rule, and a | |
| declared `a.b.c` covers a read of `a.b`. A `mapped()` inside a `raise` or a log line is phrasing | |
| a message and is skipped, as an attribute read there is. `mapped(lambda ...)` and `mapped(name)` | |
| stay unread: the fields a lambda touches are a guess from here, and every guess in this checker | |
| is a finding somebody has to read. | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 99 | 99 | | |
| | OpenSPP | 153 | 153 | | |
| | odoo core | 2,336 | **2,345** | | |
| OCA and OpenSPP do not move, and that was checked rather than taken from a quiet run. OCA has 5 | |
| computes reading through a literal `mapped()`, 3 of those reads undeclared: a `display_name` | |
| override, whose column the framework never stores, and two in a `TransientModel` wizard. | |
| OpenSPP has 22 and 3, and all three compute fields declared without `store=True`. Each is | |
| exempt for a reason the checker already had. | |
| Core has 117 such computes, and 9 findings survive the exemptions. Nothing disappears. All 9 | |
| were read: | |
| - `pos_sale`, three. `sale.order.amount_unpaid` is stored, reads `transaction_ids.invoice_ids` | |
| and `order_line.pos_order_line_ids.price_subtotal_incl`, and declares the relations but | |
| neither field. `sale.order.line.untaxed_amount_invoiced`, stored in `sale`, adds | |
| `pos_order_line_ids.price_subtotal` and declares only `pos_order_line_ids`. An amount written | |
| on a linked POS line leaves both totals where they were. | |
| - `survey`, one. `scoring_percentage` and `scoring_total` are stored "for perf reasons" and read | |
| `predefined_question_ids.suggested_answer_ids.answer_score`. The decorator names | |
| `predefined_question_ids.answer_score`, the score of a simple question, and not the scores of | |
| the answers. Editing a choice's score leaves every submitted attempt on the old percentage, | |
| while editing a simple question's score recomputes them. | |
| - `account_peppol_response` and `l10n_dk_nemhandel_response`, one each, the same shape: the | |
| stored move state reads the responses' `response_code` and declares only their state. True | |
| as the rule states it. In core `response_code` is required and written only when a response | |
| is created, which the declared relation already covers, so nothing in this tree edits one in | |
| place. | |
| - `purchase_stock` and `sale_stock`, one each: `incoterm_location` read from the linked orders. | |
| `purchase_stock` declares only `purchase_id`, a `store=False` autocomplete field; | |
| `sale_stock` declares the orders and not their `incoterm_location`. Both true, and both on a | |
| `readonly=False` compute, the smart-default pattern Β§3.51 kept as a consideration inside a | |
| verdict rather than a rule: a bill's incoterm location following its order after the fact may | |
| not be what anybody wants. | |
| - `sale_mrp`, one, false. `_compute_qty_to_deliver` reads `move_ids.bom_line_id.bom_id`, but the | |
| two fields it computes are declared in `sale_stock` without `store=True`. The file in front | |
| of the checker cannot see that; it is the storedness-in-another-module class the OCA reading | |
| left at 13 undecidable. | |
| So 8 true as the rule states it, two of them with a field nothing rewrites and two on a smart | |
| default, and 1 false. None of the nine goes into a classification file: core's is a fixed-seed | |
| sample, and these were read as a census of what one change added. All 277 hand-classified true | |
| positives are still reported. | |
| What it still does not follow, named so nobody finds it later: a lambda or a variable as the | |
| argument; `mapped()` on the result of another `mapped()`; and a loop over | |
| `rec.line_ids.mapped("move_id")`, whose loop variable is not bound to a path, because a | |
| `mapped()` result is not a recordset in general and `_resolve_iterable` still says so. | |
| ### 3.67 What backs an onchange, measured and mostly not taken | |
| **Measured 2026-09-27.** `onchange-is-not-validation` stays quiet when the file names any | |
| constraint at all. So a `UNIQUE(reference)` silences an onchange that raises on | |
| `credit_limit`, and on an Odoo 19 module a `_sql_constraints` block counts as the backstop even | |
| though Odoo 19 never creates it (Β§3.62). Two narrowings were proposed together: count a | |
| constraint only when it covers a field the onchange watches, and never count | |
| `_sql_constraints` when the tree pins 19 or later. The rule was that this ships only if what | |
| it adds reads true. Both were measured, apart and together. | |
| | | before | field-matched, and 19 | 19 only (shipped) | | |
| |---|---|---|---| | |
| | OCA | 99 | 100 | **99** | | |
| | OpenSPP | 153 | 157 | **153** | | |
| | odoo core | 2,345 | 2,376 | **2,345** | | |
| **Field matching added 36 findings, and 11 read true.** On OpenSPP, 3 of 4. `spp_area` caps | |
| an area at level 10 in `_onchange_parent_id` and nowhere else; its constraint is | |
| `unique(code)`. `spp_change_request_v2` keeps required documents inside the available list | |
| only in the form. `spp_registry`'s one-head-per-group rule is checked in the membership | |
| onchange and in the group's own `write()`, and a change request's `add_member` creates | |
| memberships directly, past both. The false one is `spp_import_match`: the parent model's | |
| `@api.constrains("field_ids")` enforces the same no-duplicates rule, under a field name the | |
| child's onchange does not watch. | |
| On core, 8 of 31. Four are the partner "block" warnings in `account`, `sale`, `purchase` and | |
| `stock`: each clears the partner in the form, and nothing refuses a blocked partner on import | |
| or RPC. That is Odoo's design for the feature, and it is also exactly the rule's sentence. The | |
| other four refuse a value nothing else checks: a loyalty card's expiration date, a negative | |
| price rounding, an expense edited by someone who may not edit it, and a manufacturing order | |
| whose component is its own product. Four of the false ones are checked on the server | |
| elsewhere: a work order's dates by the inverse `_set_dates`, the reference unit by | |
| `_check_category_reference_uniqueness`, an API key's duration when the wizard creates it, and | |
| the Argentinian journal pairing when the invoice posts. The other 19 refuse nothing. They are | |
| notices: "Note: the reference already exists", a packaging quantity suggestion, "the new rate | |
| is quite far from the previous rate", a sequence format that changed. On OCA the single | |
| addition is `account_loan`'s rate warning, and it is the same kind of notice. | |
| That last finding corrects an old reading. Β§3.45 described `account_loan` as "an onchange | |
| properly backed by" its `_sql_constraints`. The block is `unique(name, company_id)` and backs | |
| nothing the onchange watches. The finding was false because the onchange only informs. | |
| So the file-wide guard was hiding a second, wider problem: the checker counts any returned | |
| warning dict as a refusal. Narrowing the guard first would ship that noise, 25 false for 11 | |
| true, so it was not taken. A variant that counts a warning dict only when the onchange also | |
| resets a field it watches was run for information. It keeps all 11 true additions and 4 of the | |
| false ones, but it also removes 17 existing core findings and 1 on OpenSPP that nobody has read. | |
| Whether that is the right next change depended on reading those 18 first. They were read the | |
| same day, and Β§3.68 is what came of it. | |
| **The series half ships.** A `_sql_constraints` block no longer counts as backing on a tree | |
| that pins 19 or later; on 16 to 18 and on an unknown series it still does, as Β§3.45 requires. | |
| It changes nothing on the three trees: OpenSPP's modules are all on 19, but every file there | |
| that has an onchange and a constraint uses `models.Constraint` or `@api.constrains`. That | |
| includes the state before OpenSPP's own incident fix (208d9758), whose file also carried | |
| `@api.constrains` methods. It is shipped for the same reason Β§3.62 was: a declaration the | |
| registry throws away is not a backstop, and the checker should not say it is. All 277 | |
| hand-classified true positives are still reported. | |
| ### 3.68 A warning refuses only when it takes the value back | |
| **Measured 2026-09-27.** Β§3.67 left the field-matched guard out because the file-wide one was | |
| hiding a wider test: any returned warning dict counted as a refusal. Both changes ship now, | |
| together, and the second is what makes the first read true. | |
| `_refuses_in_branch` asks one thing of a warning: does the branch that returns it also take the | |
| value back? The branch is the innermost `if`, `for` or `while` around the warning, or the whole | |
| body when there is none. Taking the value back is writing a field the onchange watches (the | |
| quantity clamped to zero, the removed document restored, the component taken out again), or | |
| clearing any field on the record to `False`, `None` or empty (the blocked partner, the | |
| registrant that no longer fits). A `raise` still counts on its own. A warning with no write | |
| beside it is a notice, and the user saves past it in the form as easily as in an import, so | |
| nothing is being enforced anywhere and the rule's harm is not there. A write outside the | |
| warning's branch does not count either: `account.move` sets its `show_name_warning` flag in | |
| an earlier `if`, and that flag is not a refusal. | |
| The first draft only looked for writes to watched fields, and reading what it removed stopped | |
| it. `spp_change_request_v2`'s `_onchange_request_type_id` watches the request type and clears | |
| the registrant when a group lands on an individual-only type. Nothing else checks that pairing: | |
| the registrant's domain is a form attribute, so a change request made through the API can pair | |
| them. Clearing a field the onchange does not watch is a refusal too, and the test now says so. | |
| Setting one to a suggested value is not: a scrap order's recommended location, moved in for | |
| the user with a note, refuses nothing. | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 99 | 99 | | |
| | OpenSPP | 153 | **157** | | |
| | odoo core | 2,345 | **2,339** | | |
| Every change was read. Added: on OpenSPP the four from Β§3.67, 3 true and 1 false, now in | |
| `artifacts/classification-openspp-rest-2026-09.json` under a new class, `backed-by-the-parent-constraint`. | |
| On core, 11: the 8 true from Β§3.67 (the four partner blocks, loyalty expiry, negative price | |
| rounding, expense edit rights, a component equal to its product) and 3 of the 4 that are checked | |
| on the server elsewhere (the work order's dates, the reference unit, the Argentinian journal). | |
| The API key's duration no longer appears: its onchange only warns. OCA's `account_loan` note | |
| does not come back. | |
| Removed: 17 on core, all read, and none refuses anything. Six are advice about a value that | |
| saves anyway: an OCR number that fails its checksum, an invoice name too long for GST rule | |
| 46(b), an EGP rate with more than five decimals, a rounding finer than the decimal accuracy, a | |
| package heavier than its type allows, a pricelist minimum quantity that event tickets will | |
| ignore. The other eleven say what is about to happen: tokens archived with a payment method or | |
| provider, a payment method attached to a provider, pricelists archived with the feature, a | |
| carrier's rating error, a subcontracting purchase order, a decreased service quantity, tracked | |
| products already in stock, a storage category with no location, an open cart keeping its | |
| pricelist, and a scrap order's recommended location. None of them is in a classification file. | |
| So the rule now reports 15 more onchanges than it did and 17 fewer, and of the 15, 11 are | |
| true. OpenSPP's census gains 3 true and 1 false; core's 17 removals were notices nobody had | |
| read, and they are not claimed as false positives fixed. All 280 hand-classified true | |
| positives are still reported, the 3 new ones among them. | |
| What it still does not see: a constraint whose fields sit under another name, as | |
| `spp_import_match`'s parent `@api.constrains("field_ids")` does for its child's `field_id`, and a | |
| check the server makes in an inverse, a wizard's `create()` or at posting, none of which this | |
| checker reads as a constraint. The 3 false additions on core are that. Β§3.69 takes the first | |
| two of those three places; posting stays. | |
| ### 3.69 The same check, made again where the server runs it | |
| **Measured 2026-09-27.** Of Β§3.68's three false additions on core, two were checks the server | |
| already makes in the same class under a name the constraint test does not read. | |
| `mrp.workorder`'s `date_finished` onchange refuses to unplan one work order, and `_set_dates`, | |
| the `inverse=` of both date fields, raises the same sentence. The API key wizard's onchange and | |
| its `create()` both call `res.users.apikeys._check_expiration_date` on the expiration date. | |
| `_server_repeats_the_check` treats an onchange as backed when a method of its own class that | |
| the server runs whatever the client is (`create()`, `write()`, a field's `inverse=`, an | |
| `@api.constrains` method) makes the same check. The same check means one of two things, and | |
| both are strict because every yes suppresses a finding: | |
| - the exact message the onchange raises, raised there too, at least 20 characters so a stock | |
| word does not match. A paraphrase is not the same check; | |
| - the same check-shaped helper (`check` or `validat` in its name) called there, where the | |
| onchange hands it a field it watches. A bare `self._check_company()` in the onchange is | |
| checking something else, and `write()` calling it too says nothing about this value. | |
| Not counted: a method the client chooses to call, like an `action_` button, and posting, which | |
| is why `l10n_ar`'s journal pairing stays reported. A draft can hold the value until somebody | |
| posts, and that window is the gap the rule is about. | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 99 | 99 | | |
| | OpenSPP | 157 | 157 | | |
| | odoo core | 2,339 | **2,338** | | |
| One finding goes, the work order's, read as false in Β§3.67. The API key wizard was already out: | |
| its onchange only warns, and Β§3.68 stopped counting a bare warning. Nothing else moves on the | |
| three trees, which is the evidence that the two signals are narrow. They are there for code | |
| this corpus has not met yet, where a check written once for the form and once for the server | |
| is a common way to be careful. All 280 hand-classified true positives are still reported. | |
| ### 3.70 A dotted name is read, not matched | |
| **Measured 2026-09-27.** `constrains-ignores-dotted-names` was a regex for a quoted name of two | |
| segments, lowercase letters and underscores, anywhere in the decorator's arguments. A sweep of | |
| the checkers found two shapes it could not see. `@api.constrains('partner_id.country_id.code')` | |
| has three segments, so where the regex wanted the closing quote there was a dot. And the class | |
| had no digits, so `@api.constrains('l10n_in_gst_treatment', 'partner_id.l10n_in_pan')` was | |
| invisible, as was any dotted name through an `l10n_` field. A check that looks like it runs | |
| and does not is what the rule is about, and its own checker was one. | |
| `constrains_declares_a_dotted_name` reads the arguments the way the other constrains checker | |
| already did, with `_decorator_args`, and reports each method whose declaration contains a dot, | |
| on the decorator line. The message says what is left to trigger the check: nothing when every | |
| name is dotted, and the plain names when some are. On 18.0 a dotted name is not an error. | |
| `_constraint_methods` logs "is not a field name" the first time the model collects its | |
| constraints, and `_validate_fields` never finds it among the written fields (`odoo/models.py`, | |
| 18.0 at 3c3e3b3). A computed list, `@api.constrains(*FIELDS)` or the callable form Odoo also | |
| accepts, goes to the gap channel, as it does for `constrains-must-list-every-field-read`. Both | |
| checkers meet the same unread method, so `_note_unreadable` now records a method once. Twice, | |
| `check` would print it twice and fieldtest would count it as two files it could not fully read. | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 99 | 99 | | |
| | OpenSPP | 157 | 157 | | |
| | odoo core | 2,338 | 2,338 | | |
| Nothing moves, row for row, and that was measured rather than assumed. The same AST read over | |
| every `@api.constrains` in the three trees, tests included, finds 815 decorators (497 on core, | |
| 43 on OCA, 275 on OpenSPP) and not one dotted name. 41 name a field with a digit in it, which | |
| the regex would have missed had it been dotted. The 9 computed lists are all on core and were | |
| gaps already. The rule stays in the NEVER FIRED list and keeps its `unproven` grade. | |
| Its value is somewhere else: in client code under `nexa check`, and in generated answers, where | |
| an `@api.depends` beside the constraint takes the same dotted name and means it. That is the | |
| mistake the rule was written for (Β§3.7), and the three-segment spelling of it would have | |
| passed there too. The corpus now decides 8 rules by a regex and 19 by an AST pass. All 280 | |
| hand-classified true positives are still reported. | |
| ### 3.71 What a string names, and what a subscript picks | |
| **Measured 2026-09-27.** Β§3.66 taught the depends check that `x.mapped("a.b")` reads `x.a.b`. | |
| The sweep that found Β§3.70's gap found the siblings of `mapped()` still silent, and one more | |
| place where a path stopped: | |
| - `x.filtered("is_paid")` reads `x.is_paid`, and `x.filtered("a.b")` reads `x.a.b`. | |
| - `x.sorted("sequence")` reads `x.sequence`. Odoo 18 takes one field name there, as | |
| `itemgetter(key)`. On 19 the argument is an order, `"date desc, id"`, whose terms each name a | |
| field (`_sorted_order_to_function`, `odoo/orm/models.py` on 19.0). | |
| - `x.filtered_domain([("state", "=", "done")])` reads `x.state` for every leaf written out, and | |
| through an `any` leaf to the related records. `x.grouped("f")` keys on `x.f`. | |
| - `rec.line_ids[0].amount` read nothing past `line_ids`, because `_attr_path` and | |
| `_resolve_iterable` stopped at a subscript. `rec.partner_id.child_ids[:1].email` reported | |
| `partner_id.child_ids` and never the email, which is the field whose change goes unnoticed. | |
| `_named_reads` replaces `_mapped_reads` and reads all five methods, literals only. A lambda, a | |
| name and a domain built at run time stay unread, and an order with any word after a field but | |
| `asc`, `desc`, `nulls`, `first` or `last` is read not at all rather than in part. A subscript is | |
| looked through when its index is a literal integer or a slice, since indexing or slicing a | |
| recordset gives records of the same path. The other thing a subscript does is `record[name]`, | |
| which reads the field called `name`, so a string or a variable as the index stops the path as it | |
| did before. Every exemption the check has applies to every new read: a trailing `.id`, a field | |
| the method computes, a non-stored compute, a wizard, `display_name`, a declared longer path, and | |
| a read inside a `raise` or a log line. | |
| `constrains-must-list-every-field-read` had only ever read attributes, so | |
| `if any(self.mapped("date_end"))` under `@api.constrains("date_start")` was silent. It was | |
| measured with the same reads before it took them. `depends-must-not-name-unread-fields` steps | |
| aside when the body reads a field its decorator does not declare, because the depends check owns | |
| that disagreement, and it now counts the same reads when it decides. That moved nothing on the | |
| three trees. | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 99 | **101** | | |
| | OpenSPP | 157 | **163** | | |
| | odoo core | 2,338 | **2,358** | | |
| Nothing disappears, and no other rule moves. By part: the literal forms add 1, 6 and 11 depends | |
| findings on OCA, OpenSPP and core, the subscripts add 6 on core, and the constrains check adds 1 | |
| on OCA and 3 on core. `filtered_domain()` and `grouped()` add nothing on the three trees. They | |
| name fields exactly as `filtered()` does and are kept for code these trees do not hold. A loop | |
| over `enumerate()`, the other shape the sweep sized, added nothing and was left out. | |
| Every addition was read. | |
| **OCA, 2, both true**, now in `artifacts/classification-oca-2026-09.json`. `account_move_name_sequence` | |
| sets `made_sequence_gap` to `False` for moves outside a hashed journal with | |
| `self.filtered("journal_id.restrict_mode_hash_table")`, and neither its decorator nor | |
| `account`'s, which Odoo merges with it, names the flag: securing a journal leaves its existing | |
| moves on `False`. `account_spread_cost_revenue`'s journal check reads | |
| `mapped("line_ids.move_id")` and declares only `journal_id`. True as the rule states it, though | |
| every move the module creates for a line takes the spread's own journal. | |
| **OpenSPP, 6, all true**, in `artifacts/classification-openspp-rest-2026-09.json` with the other findings | |
| later changes surfaced. `artifacts/classification-openspp-2026-09.json` is the reading of the two flagship | |
| rules made on 2026-09-16 and is quoted as that reading. Five are `spp_programs`' registrant | |
| counts, each set to 0 for the partners `self.filtered("is_registrant")` leaves out, without | |
| `is_registrant` declared. Every create path in the tree sets the flag at creation and nothing | |
| rewrites it. The sixth is `spp_approval`'s current tier, the first pending tier review by | |
| `sequence`. A review's `sequence` is a stored related of its tier's, so reordering a | |
| definition's tiers while reviews are pending leaves each review on the tier the old order put | |
| first. | |
| **Core, 20: 10 true, 10 false.** The true ones: | |
| - `hr_expense`'s report is `reversed` once every entry has a reversal | |
| (`filtered("reversal_move_ids")`). A company-paid report's entries are `entry` moves, whose | |
| `payment_state` and `amount_residual`, the two fields declared, never move on a reversal, so | |
| the report stays paid. | |
| - `hr_holidays`' accrual plan takes its unit from `level_ids[0].added_value_type`. Changing the | |
| plan's time off type recomputes every level's unit, and the plan's own, which allocations | |
| read, stays where it was. Each level's copy of the first level's unit is the same shape, | |
| reached only by writing the first level directly: the form writes `level_ids` on the plan, | |
| which is declared. | |
| - `hr_holidays_contract`'s multi-contract check filters on `employee_id` and declares the dates. | |
| Moving a leave to an employee whose contracts under it keep different schedules is not | |
| checked. | |
| - `website_sale_gelato`'s publishing check filters on `gelato_template_ref` and declares | |
| `is_published`. A template set on a published product whose print images have no data is not | |
| checked. The core sample already holds its sister check on `product.document` as true. | |
| - `stock.scrap` takes its quantity from `move_ids[0].quantity` and declares | |
| `move_ids.move_line_ids.quantity`, one of the two things a move's quantity is computed from. | |
| The other, the lines' unit, is the gap. Narrow. | |
| - `point_of_sale` copies the first POS category's colour onto the product and does not follow | |
| a later change to it, on a `readonly=False` default. What Β§3.66 said of `incoterm_location` | |
| applies. | |
| - Three with a field nothing in core rewrites, as Β§3.66 counted `response_code`. | |
| `base_automation` gives an automatic name only to the actions `filtered("base_automation_id")` | |
| keeps, and every core path creates the action already linked. `sale_expense_margin` costs a | |
| reinvoiced line from its `expense_id`, set when the line is created. `l10n_id_efaktur_coretax` | |
| names a document after its first and last invoice by `sorted("name")`, and a posted invoice | |
| changes name only by resequencing. | |
| The false ones are classes the check already had for attribute reads. None comes from reading a | |
| line wrongly: | |
| - storedness in another module, two. `icon_image` in `base` and `highest_name` in `account` are | |
| declared without `store=True`. | |
| - a decorator merged from another module, one. `sale_timesheet`'s project partner calls | |
| `super()`, and `sale_project`'s `_compute_partner_id` declares `allow_billable`. Odoo merges | |
| `@api.depends` across every override (Β§3.59), and the file in front of the check shows only | |
| its own. | |
| - covered through another declared compute, two. `hr_expense` filters on `product_has_cost`, | |
| which depends only on `product_id`, and the declared `tax_ids` depends on `product_id` too. | |
| `website_slides` filters on `is_category`, and flipping it re-sections the channel through | |
| `_compute_category_id`, which changes this slide's `slide_ids`, which is declared. This is the | |
| class the OpenSPP classification calls `covered-by-its-own-depends`, one step further removed. | |
| - a value kept on purpose, two. An `account.payment.term.line` defaults its days to the | |
| previous line's plus 30 only while they are unset, so declaring the sibling's days would | |
| overwrite a deliberate 0 and make the field depend on itself. `l10n_id_efaktur`'s document | |
| name is `readonly=False`, with a docstring saying it is computed at creation and may be | |
| renamed. | |
| - refused by the model while it matters, two. The POS session's cash journal reads its payment | |
| methods' `is_cash_count` and `journal_id` (`filtered("is_cash_count")[:1].journal_id`), and a | |
| payment method refuses every write but `sequence` while a session using it is open. | |
| - advice that cannot be taken, one, on the constrains side. `mail.activity.schedule` filters on | |
| `error`, a non-stored compute that `@api.constrains` cannot name: Odoo logs it as not | |
| writeable. The decorator lists `error`'s writable inputs instead, and a comment says so. | |
| So 28 findings, 18 true. On core the depends additions read 8 of 17, below the 11 of 15 that | |
| rule's core sample read (Β§3.51), and the constrains additions 2 of 3, beside that rule's 10 of | |
| 15 (Β§3.57). On OCA and OpenSPP they read 8 of 8. Each part was weighed apart, as | |
| Β§3.67 weighed its two, and none is left out. Each adds more true findings than false, and the | |
| false ones are not a new kind of mistake: they are what this check already says when the same | |
| field is read as an attribute, and keeping a spelling silent would not fix them, only hide them | |
| behind the spelling. None of the core readings goes into `artifacts/classification-odoo-core-2026-09.json`, | |
| which is a fixed-seed sample. The hand-classified true positives are now 288, and all 288 are | |
| still reported. | |
| What it still does not follow, named so nobody finds it later: | |
| - a lambda, a name or a variable as the argument, a domain built at run time, and an Odoo 19 | |
| `Domain` object; | |
| - a subscript by a name, `rec.line_ids[i]`, which could be either kind of subscript; | |
| - a name assigned a recordset (`pending = ...sorted("sequence")[:1]`, then `pending.tier_id`): | |
| `_aliases` binds loop variables only; | |
| - `mapped()` on the result of another `mapped()`, and a loop over a `mapped()` result, as Β§3.66 | |
| left them; | |
| - an attribute read straight off a call, `rec.sudo().partner_id.name` or | |
| `rec.line_ids.filtered(...).amount`. Sized and not read: core +9 on the depends check (six | |
| through `sudo()`, three through `with_context()`), OCA +1, OpenSPP 0; | |
| - a read another declared path already reaches: a read compute's own dependencies | |
| (`product_has_cost` above, and OpenSPP's three older `covered-by-its-own-depends`), or a flag | |
| whose change re-sections a declared relation (`is_category` above). Following either would be | |
| a narrowing of its own. | |
| ### 3.72 What a lambda reads, measured and not taken | |
| **Measured 2026-09-27.** Β§3.66 and Β§3.71 left a lambda unread, and Β§3.66 gave the reason: "the | |
| fields a lambda touches are a guess from here". For the recordset methods that reason does not | |
| hold. `x.filtered(lambda r: ...)` calls the lambda with every record of `x`, and so do | |
| `mapped()`, `sorted()` and `grouped()`; `sorted(x, key=...)`, `max()` and `min()` do the same by | |
| iterating `x`. The parameter is bound to `x`'s records exactly as a loop's target is, so `r.f` | |
| is a read of `x.f`. Whether that read is a dependency is a separate question, and this section | |
| is the answer to it. | |
| The binding was built, each lambda given its own scope with its first parameter bound to the | |
| receiver's records, and measured against fed0255, the tree before Β§3.70 and Β§3.71 landed. | |
| Depends and constrains were weighed apart: | |
| | | before | depends | constrains | | |
| |---|---|---|---| | |
| | OCA | 99 | +8 | 0 | | |
| | OpenSPP | 157 | +1 | 0 | | |
| | odoo core | 2,338 | +127 | +27 | | |
| Every one was read against the code, and **they do not read true.** On core, 40 of the 127 | |
| depends findings are true, and 28 of those 40 are smart defaults, `readonly=False` computes, | |
| which Β§3.51 kept as a consideration inside a verdict rather than a rule. Twelve are a stored | |
| value that goes stale. Constrains reads 9 of 27. OCA reads 2 of 8, judged the way core was, and | |
| OpenSPP's one is true. Β§3.71 took each of its parts because each added more true findings than | |
| false. These add about twice as many false as true, depends and constrains alike, and OpenSPP's | |
| single finding cannot carry that. | |
| The reason is what a lambda is for. A loop body computes the value from its inputs. A | |
| `filtered()` predicate decides which records get which treatment, and the fields it tests say | |
| what kind of record this is. Core's 87 false depends findings: | |
| - 28 test an identifier or a type that nothing changes after creation, or that the model | |
| refuses to change. Six are `not line.display_type` on sale and purchase lines, whose | |
| `write()` raises on a change of type. The rest include an EDI document's format, a country's | |
| ISO code, a done picking's type and destination, a location's usage, a POS order line's | |
| company, a model's technical name and `create_date`. | |
| - 12 are a guard on which records to touch: drafts only, e-way bills still pending, entries | |
| not yet hashed, sale lines whose purchase price is kept. | |
| - 12 sit in a compute whose own field another module declares without `store=True`, outside | |
| the rule. | |
| - 19 are covered already, in a way the file cannot show. Seven by the parent method's | |
| decorator, which Odoo merges across overrides (Β§3.59). Seven through a declared computed | |
| field that depends on them, since Odoo's triggers are transitive: a task's `project_id` is | |
| computed from `parent_id.project_id`, so a compute declaring `project_id` already follows the | |
| parent. Five through a declared field that always changes with them: a picking is `done` | |
| exactly when `date_done` is set. | |
| - 9 read the method's own output, filled through a helper, `update()` or `write()`. | |
| - 7 are one of a kind: moves sorted by `ref` only to number a batch in order, residuals Odoo | |
| rewrites in SQL when an account's reconcile flag is written, a Greek invoice type the compute | |
| keeps once set, a journal flag that cannot change while it matters, a clause that runs only | |
| for unsaved records, and a service type the parent already sets for every good. | |
| The constrains additions read the same way. Of the 18 false, 11 name a non-stored related or | |
| computed field, seven of them `country_code`, and `_constraint_methods`, the collector Β§3.70 | |
| reads, logs such a name in `@api.constrains` as not writeable. Six name a field fixed at | |
| creation: a payment provider's | |
| `code` on three providers, a Gelato print image's flag, a journal item's move, and a | |
| sub-channel's parent, which `write()` refuses. The last is a move's company, which re-derives | |
| the journal the check declares. | |
| What is true is worth naming: | |
| - `account.move.line._compute_amount_residual` decides which journal items carry a residual by | |
| their account's reconcile flag and type, and declares only `account_id`. For the flag, Odoo | |
| rewrites the residuals by hand in SQL when it is written (`_toggle_reconcile_to_true` and | |
| `_toggle_reconcile_to_false`), so that half is false. The type has no such hand: switching an | |
| account to Bank and Cash leaves its items' residuals where they were. | |
| - `mrp.production._compute_reservation_state` filters the components on product, `picked`, | |
| quantity and unit, and declares only their `state`, which `_recompute_state` writes only when | |
| it changes. Marking a component picked leaves "MO Readiness" as it was. | |
| - On constrains, `account_report`'s domain check declares `formula` and filters on `engine`, so | |
| switching an expression's engine to domain over a formula that is not one is never checked. | |
| So too a barcode rule switched to GS1 over a pattern without its two groups, a journal made an | |
| AFIP point of sale with no number, a loyalty rule switched to a code, and a paper format given | |
| a page size beside a named format. | |
| OCA's two are `account_check_deposit`'s in-hand account, which follows a payment method line | |
| whose method can be changed, and `account_move_name_sequence`'s split sequence, which skips moves | |
| outside a hash-secured journal and does not re-run when a journal becomes one: the flag Β§3.71 | |
| found in the same module's gap check. Its six false are a field core declares without | |
| `store=True`, a journal item's move, a posted bill's type, a payment method's code, and the | |
| journal and type of a move that already has a sequence number, which `write()` and | |
| `action_switch_move_type` both refuse to change. OpenSPP's one is `spp_area`'s center areas, | |
| filtered on a role line's `role_type`, a related of the role's type: a role switched from local | |
| to global through the API keeps its areas on every user, where the form's onchange clears them. | |
| So the binding is not taken, and a test pins that. What would separate true from false here is | |
| facts from outside the file: which fields are stored, related, computed from what, merged from a | |
| parent's decorator, refused by `write()`. The tree facts the ondelete and check_company triggers | |
| read are where that would start. It is proposed, not built, and even built it would leave the | |
| guards and every identifier the model does not refuse to change, 34 of the 87, which no | |
| declaration decides. | |
| Two narrower questions were read on the way. | |
| **A sort orders the batch only when it sorts the batch.** Four of the findings the binding | |
| reached came from `sorted()`. Two sort `self`, the records being computed: `account.move._compute_name` numbers a | |
| batch by date and `ref`, and no move's name follows another's `ref`, while | |
| `_compute_balance_start` sorts statements by first line, a field it also reads for each | |
| statement, so that finding stood already. The other two sort a relation and pick from it | |
| (`[:1]`, or the first with an email), which is a dependency when the key can change, as a bank | |
| account's `allow_out_payment` can and `create_date` cannot. The line runs between `self` and a | |
| relation, not between iterating and picking. | |
| **A lambda's parameter hides a loop variable of the same name**, and `_aliases`, one map for the | |
| whole function, does not know it. In event's `_compute_event_mail_ids`, | |
| `lambda mail: mail._origin.mail_done` runs over the event's own mail lines, and a loop further | |
| down binds `mail` to the event type's. The check reports | |
| `event_type_id.event_type_mail_ids._origin.mail_done`, a path the method never reads, twice. The | |
| binding put `mail` on the event's own lines, whose field the method computes, and both went. | |
| Hiding the parameter without binding it was measured too, and it is worse. The common spelling | |
| is `for x in self.filtered(lambda x: ...)`, where the loop and the lambda walk the same records | |
| and the one map is right by accident. Hiding took nine of those along with the two, and at least | |
| two of the nine are true: `ir.actions.server`'s mail and SMS template checks filter on the | |
| action's type, `state`, and do not declare it, so switching an action to sending an SMS over | |
| another model's template is never checked. Only the binding fixes the two without losing the | |
| nine, so they stay with it. | |
| **What ships is the first false class: the method's own output.** `account.account`'s | |
| `_compute_account_type` filters on `not account.account_type` and hands the records to | |
| `_get_closest_parent_account` with the field's name. The check already exempted a field the | |
| method computes, but it knew only what the method assigns, `rec.total = ...`, and a helper, | |
| `update()` or `write()` makes no such store. So the field a method produces read as a field it | |
| depends on. `_computed_fields` now maps each method to the fields its file declares with | |
| `compute="<that method>"`, and those count as its output too. A field another method computes is | |
| still an input, and the same field read off a relation, `parent_id.rrule`, is still a recursive | |
| dependency Odoo wants declared. | |
| That does not need the binding to matter. On the trees as they are: | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 101 | 101 | | |
| | OpenSPP | 163 | **161** | | |
| | odoo core | 2,358 | **2,357** | | |
| All three were read. OpenSPP's two are the pair its flagship classification already marked false | |
| as `self-read`: `spp_area` clears and refills `center_area_ids` through `update()`, and | |
| `spp_service_points` reads the service points it computes. Core's one is | |
| `calendar.recurrence._compute_rrule`, which compares `rrule` with the serialized rule and | |
| `write()`s it when they differ. With the binding, the same exemption took nine of its 87 false | |
| findings on core, the `_compute_account_type` the sweep started from among them. Nothing is | |
| added, and all 288 hand-classified true positives are still reported. | |
| ### 3.73 A path through a method's own output, measured and kept exempt | |
| **Measured 2026-09-27.** The depends check never asks a compute to declare the field it | |
| produces, and it used to give the wrong reason: that the ORM refuses it. Odoo 18 refuses | |
| nothing here. A field named in its own `@api.depends` is dropped from its triggers without a | |
| word (`resolve_depends`, "do not make self trigger itself", `odoo/fields.py` 842-846), so that | |
| advice would do nothing. The skip covers only the field's own name, though. A path through it, | |
| `@api.depends("team_id.member_ids")` on the compute of `team_id`, is kept, and the compute | |
| re-runs when the team's members change. The exemption skips every read rooted at the output, | |
| `lead.team_id.member_ids` included, so the part of it that rested on "cannot be followed" was | |
| measured: the exemption narrowed to the bare field, on the three trees. | |
| | | before | bare field only | | |
| |---|---|---| | |
| | OCA | 101 | 101 | | |
| | OpenSPP | 161 | 161 | | |
| | odoo core | 2,357 | 2,394 | | |
| All 37 were read, and every one is the same shape: a compute that keeps its current value | |
| while it still fits and picks a new one otherwise, reading the value it chose earlier to | |
| decide. Declaring the path would re-run it when the record it chose changes. | |
| **17 are true as the rule states it, and 16 of those are smart defaults**, `readonly=False` | |
| computes (Β§3.51). A lead keeps its sales team while its salesperson is a member | |
| (`team_id.member_ids`, `team_id.user_id`), as a lead mining request does. A lead or a task | |
| keeps its stage while the stage belongs to its team or project. A contract keeps its salary | |
| structure type while the type's country matches, and an allocation its accrual plan while the | |
| plan's time off type matches. Maintenance equipment and requests keep their team, and a request | |
| its technician, while they belong to its company. A reconcile model line keeps its journal | |
| while the journal's type matches, a tax its tax group while the group's country and company | |
| match, a product its purchase unit while it shares the unit's category, and a chatbot step the | |
| answers that trigger it while their steps come first. In each, the kept value goes stale when | |
| the configuration it was checked against changes, and in each a recompute would replace a value | |
| somebody may have chosen; Β§3.51 reads that as a decorator kept narrow on purpose. The one plain | |
| true is `sale_project`'s task sale order. The method fills the task's partner only when it is | |
| empty, so the partner is mostly an input, and re-parenting it under another company leaves the | |
| sale order in place. | |
| **20 are false.** In 12, what the output points to cannot change what it is: a variant's | |
| template, the BoM a BoM line or an operation belongs to, a production move's BoM line, a work | |
| order's operation, a packaging's product, a milestone's project, a skill's type, a chatbot | |
| answer's step, a custom value's attribute. In 7, `write()` refuses the change: the company | |
| hierarchy ("The company hierarchy cannot be changed", twice), a picking type's or a location's | |
| company (three), a journal's company once it holds entries, and a base field's relation. The | |
| last acts only when a declared field changes: `hr_holidays` looks at the leave type's | |
| `requires_allocation` only for a leave whose employee has just changed in the form. | |
| So the narrowing reads 17 true to 20 false, and 16 of the 17 re-check a choice the compute made | |
| itself. Β§3.71 took its parts because each added more true findings than false; this adds | |
| fewer. The exemption stays whole, now on a measurement rather than on a premise that was wrong, | |
| and a test pins it. Nothing moves on the three trees, and all 288 hand-classified true | |
| positives are still reported. | |
| ### 3.74 A claim written as a list | |
| **Measured 2026-09-27.** `layers.cited_ids` decides what an answer claims to have applied, and | |
| it read the `Rules applied:` line from the colon on. The prompt asks for the ids comma separated | |
| on that line, and the 7B does not always oblige. Re-capturing the README's plain-script sample | |
| (3.75), it wrote the label on its own and listed its four ids under it, one per line. That | |
| answer is a fixture now, `LOAD_PRICES_BULLETED`. Read from the label's line, the 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. | |
| The second half is the one that mattered. On the line, an id with no rule behind it is a | |
| critical finding and a FAIL. In a list it was never looked at, so an invented citation in that | |
| shape went through. `corpus.report_citations`, behind `nexa verify`, kept its own copy of the | |
| parse with the same gap, and calls `cited_ids` now. | |
| The claim is the label's line and, when nothing follows the colon, the list directly under it. | |
| Blank lines before the first item are skipped, and the first line that is not a list item ends | |
| it. A code block is still never a source of citations, and a rule named under `Not handled:` is | |
| still not a claim to have applied it. Tests pin the recorded answer, an invented id in its list, | |
| and where the list ends. | |
| On the check subjects, the first run of 3.75, made before the fix, had 4 of its 15 reviews | |
| citing nothing the parser could read: all four on `load_prices.py`, and all four PASS. The | |
| committed run, made after it, has none. The verdicts did not follow the citations, though. | |
| `load_prices.py` passed on all five committed runs with its citations read, since UNKNOWN on the | |
| knowledge layer never stood in the way of a PASS. What those answers escaped was the check on | |
| what they cited, not a verdict. | |
| ### 3.75 The check and retrieval rows, read from their runs | |
| **Measured 2026-09-27.** The README's `nexa check` rows rested on `artifacts/eval-check-2026-09.json` from | |
| 2026-09-14. Two of its five subjects, `subjects/web/src/partners.ts` and `board.js`, went with the | |
| TypeScript and JavaScript corpora in 8d155aa, and `nexa check` refuses both now. The rows still | |
| agreed with the artifact, and nothing read the artifact, so nothing said so. The retrieval rows | |
| had the opposite weakness: re-measured by hand whenever a rule changed, with no run committed to | |
| check them against, and 8b165a1 found them a rule behind. | |
| The check run is redone over the three subjects left, five runs each, and committed over the old | |
| one. The old artifact held the second of the two runs the README quoted: | |
| | | 2026-09-14, five files | 2026-09-27, three files | | |
| |---|---|---| | |
| | Reviews | 25 | 15 | | |
| | Findings reported about the files | 23, in 5 of 5 | 10, in 3 of 3, and 3 advisory apart | | |
| | The same findings on every run | 5 of 5 files | 3 of 3 files | | |
| | PASS / WARN / REJECT | 0 / 12 / 13 | 5 / 5 / 5 | | |
| | Same verdict every run | 3 of 5 | 1 of 3 | | |
| | Rules the model cited beyond the checkers | 3 | 1 | | |
| **Two counts in the eval were wrong, and neither was the tool's.** `file_finding_count` dropped | |
| suppressed hits and kept advisory ones, though its comment said live findings only: the advisory | |
| grade came after it. On these files that read 13 where `check` prints 10 and `fieldtest` counts | |
| 10, with 3 advisory apart. It counts what `check` counts now, and advisory hits go in a field of | |
| their own that the report prints the way `check` does. The other was the citation parse, 3.74. | |
| Both were found reading the first run, which is why the committed run is the second. | |
| **What the run says.** The deterministic half is what it was: the same ten findings on every | |
| review, and the same ten `fieldtest` finds with no model. `load_prices.py` carries four of them | |
| where it carried eight, since three of the plain-Python rules were narrowed in between (3.47, | |
| 3.61). The model | |
| cited one rule beyond the checkers in fifteen reviews, `check-constraint-cannot-cross-tables`, | |
| the rule pinned into every review, and it cited it on the plain script, where there is no | |
| constraint for it to be about. The verdicts spread evenly, and only `load_prices.py` held one for | |
| all five runs: PASS, for an answer that reproduces the file with its defects repaired. The file | |
| itself still exits 1. `--no-model` took 0.42 to 0.52 seconds on the subjects and 0.78 on | |
| `area_core.py`, against 0.26 on 2026-09-14, so the README says under a second. | |
| **figures.py reads both runs now.** `check.*` comes from `artifacts/eval-check-2026-09.json`: files, runs | |
| and reviews, the findings taken once per file, whether they came out the same on every run, the | |
| verdict counts, stability, and citations beyond the checkers. `--check` fails on a row naming a | |
| file the repository no longer has, which is the drift this entry started from, and on files | |
| reviewed unevenly. | |
| `retrieval.*` comes from `artifacts/retrieval-2026-09.json`, the rows of `nexa eval --retrieval --json`, | |
| which now writes a head beside them: the depth it ranked at, the embedder, and the corpus hash | |
| it ranked against. One run at the operating depth answers both published figures, since | |
| `retrieve()` fills its slots in one fixed order, and a separate top-3 run agreed with it on all | |
| 28 rules. `--check` fails when a rule is missing from the run and when the hash differs from | |
| `corpus_meta.json`. The ids alone would have caught the drift 8b165a1 corrected, a rule added, and missed | |
| this entry's other change, a rule reworded. | |
| That rewording is `constrains-ignores-dotted-names`, whose title said "silently ignores". Odoo | |
| 18 logs "@constrains parameter ... is not a field name" when the model first collects its | |
| constraints (3.70), so the title reads "ignores dotted field names without an error". Retrieval | |
| was measured before and after it: 28 of 28 at top 6 and 26 at top 3 both times, and no rule | |
| changed rank. The rule itself stays second for its own probe, behind the pinned rule, at a | |
| score of 0.700 where it was 0.694. | |
| **Left.** The README's `nexa ask` stability row and escalation row rest on `artifacts/eval-2026-09.json` | |
| of 2026-09-14, and six of its twelve questions were TypeScript and JavaScript: 10 of the 21 | |
| escalations and 5 of the 11 rescues. The rows now carry that date and say so. Re-running them | |
| needs the 30B for the escalations, which is a session of its own. | |
| ### 3.76 The probes' numbers, committed and checked | |
| **Measured 2026-09-27.** The README said the corpus "currently meets all three" probes, with | |
| 99/99 rewrites still caught, 0 of 13 satisfied patterns bypassable and 0 contradictions, and | |
| that "those numbers are regenerated on every build, so a checker cannot quietly weaken". The | |
| build did re-run the probes, but nothing wrote their numbers down, and the sentence had not | |
| moved since TypeScript and JavaScript left the corpus (8d155aa). The figures are 82/82 and | |
| 0 of 1. The coverage table had 82/82, corrected by hand in 8b165a1. | |
| The claim was untrue in a second way. `adversarial.py` fails only when a critical rule loses a | |
| rewrite, so a high or medium checker that stopped seeing a rewritten sample would have passed | |
| the build and CI while the README went on quoting the old count. | |
| `adversarial.py all --json` now writes every row the three probes produce: one per rewrite and | |
| whether it was caught, one per satisfied_pattern with the comment it was attacked with, one per | |
| cross-hit with its class, and the corpus hash. There is no timestamp, so two runs over the same | |
| corpus and checkers write the same bytes, and `--check` compares a fresh run with the committed | |
| file, fails on any difference, and names the counts that moved. The report the probes print is | |
| unchanged byte for byte; it is printed from the same rows. `nexa build` writes the file on | |
| every build, CI's corpus job runs `--check`, and figures.py reads `adversarial.*` from it, so | |
| the coverage rows and that paragraph are marked. The README and `nexa help fieldtest` said | |
| "32 samples"; they say one per rule now. | |
| A checker change and a corpus change both fail CI until the file is rewritten, and figures.py | |
| sees a corpus change on its own, from the hash. The coverage table's cross-hit row belongs to | |
| crosscheck, which has no artifact yet, and stays unmarked here; Β§3.85 marks it. | |
| ### 3.77 The pipeline rows, re-measured on Odoo alone | |
| **Measured 2026-09-27.** The README's stability and escalation rows rested on | |
| `eval-2026-09.json` of 2026-09-14: twelve questions, six of them TypeScript and JavaScript, | |
| which left the corpus in 8d155aa. 10 of its 21 escalations and 5 of its 11 rescues were on | |
| those. It is re-run over the nine questions `eval.py` asks now, all Odoo, five runs each: 45 | |
| pipeline runs in 32 minutes on the 6 GB card, the 30B split 22% GPU and 78% CPU. | |
| | | 2026-09-14, twelve questions | 2026-09-27, nine Odoo questions | | |
| |---|---|---| | |
| | PASS / UNVERIFIED | 50 / 10 | 38 / 7 | | |
| | Same verdict every run | 12 of 12 | 8 of 9 | | |
| | Escalation triggered | 21 of 60 | 18 of 45 | | |
| | Escalation rescued a bad draft | 11 of 21 | 11 of 18 | | |
| figures.py reads `pipeline.*` from the rows, and fails on an answer for a stack the corpus | |
| no longer has, the drift this entry started from, and on rows without the escalation fields, | |
| which is an ask run read as a pipeline run. The README rows are marked, and name `nexa run`, | |
| which is what `--pipeline` measures. | |
| **Two questions moved, and one of them is a finding.** The onchange question, UNVERIFIED five | |
| times on 2026-09-14, now escalates every run and the 30B repairs it every run. The | |
| negative-quantity question went the other way, PASS five times then and UNVERIFIED five times | |
| now. Both models answer it with an `@api.constrains` comparing the quantity to zero, and | |
| `prefer-sql-constraint` flags that shape: graded advisory in 3.38, a preference and not a | |
| defect. The semantic layer reports the finding and keeps it out of its verdict, as 3.38 says | |
| it must. The domain layer checks the same rule and lets it decide, and `worth_escalating` | |
| counts it either way. So the draft escalates on a preference, the 30B writes the same | |
| constraint, and the run ends UNVERIFIED. | |
| The evidence bundles of the run (local, not committed) say how far that reaches. Of the 18 | |
| escalations, 10 were triggered by an advisory finding and nothing else: the five | |
| negative-quantity runs, none rescued, and the five runs of the positive-amount question, all | |
| five counted as rescued when the 30B's answer happened not to trip the rule. Without them the | |
| rescue rate is 6 of 8. The eval rows record whether a run escalated, not why, so the README | |
| row keeps 11 of 18 and this entry keeps the reason. | |
| 3.38 says an advisory finding gates nothing and nothing fails because of one. The pipeline's | |
| own tests say otherwise: their escalation example is `NEGATIVE_QTY`, "a single-column bound | |
| that belongs in a CHECK", written before 3.38 graded the rule. Which of the two holds is a | |
| decision about the design rather than a bug in either, so nothing here changes it, and the | |
| numbers above are what the tool does today. | |
| Decided 2026-10-01: Β§3.38 holds. Β§3.81 makes the answer layers agree with it, and re-measures | |
| both rows. | |
| ### 3.78 An attribute read straight off a call | |
| **Measured 2026-09-28.** Β§3.71 left `rec.sudo().partner_id.name` unread: `_attr_path` stopped | |
| at a call. `_resolve_iterable` already knew which calls hand back records of the same path, | |
| `sudo()`, `with_context()`, `filtered()`, `sorted()` and `exists()`, because a loop's iterable | |
| goes through it, so a chain whose base is a call now resolves through that same list. A loop | |
| and a read cannot disagree about it. `mapped()` stays out, because it returns values, and so | |
| does any other call, `browse()` and a method on the record included. | |
| The three trees were scanned with the committed checkers first, and each scan reproduced its | |
| artifact hit for hit, suppressed and advisory findings included. Then with the change: | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 101 | **102** | | |
| | OpenSPP | 161 | 161 | | |
| | odoo core | 2,357 | **2,366** | | |
| All ten are the depends check, as Β§3.71 sized them: core's nine through `sudo()` (six) and | |
| `with_context()` (three), OCA's one through `filtered()`. Nothing disappears, and no other rule | |
| moves: `depends-must-not-name-unread-fields` already steps aside for any body that calls a | |
| method on its record, and `rec.sudo()` is one. | |
| Every addition was read. **OCA, 1, true**, now in `artifacts/classification-oca-2026-09.json`. | |
| `account_check_deposit` stores its in-hand check account from the `payment_account_id` of the | |
| journal's manual inbound payment method line, read through `filtered()`, and declares only the | |
| journal. A deposit drafted before that account is set stays empty after it is set, and | |
| `get_all_checks` goes on raising the error that tells the user to set it. The sibling finding | |
| on the same line, the payment method lines themselves, was already read true. | |
| **Core, 9: 4 true, 5 false.** The true ones: | |
| - `account.move`'s partner display name, for a move with no partner and no source email, is | |
| "#Created by:" and `move.sudo().create_uid.name`, and declares neither. Renaming the user | |
| leaves every such bill on the old name. `create_uid` itself is set once, at creation, and | |
| is true as the rule states it, as Β§3.66 counted `response_code`. | |
| - `l10n_sa_edi`'s QR code reads the journal's production CSID certificate and declares the | |
| CSID JSON. True as the rule states it: in core the certificate is written in one place, on | |
| the line after the JSON, so the declared field always moves with it. | |
| - `loyalty.reward`'s description names the product through `with_context()` and does not | |
| follow a rename, on a `readonly=False` default. What Β§3.66 said of `incoterm_location` | |
| applies. | |
| The false ones are classes the check already has for attribute reads: | |
| - storedness in another module, three. `pos_sale` reads its order's POS lines through `sudo()` | |
| in its overrides of `_compute_amount_to_invoice` and `_compute_amount_invoiced`, and `sale` | |
| declares both fields without `store=True`, so they never go stale. | |
| - covered through another declared compute, one. `certificate`'s private key reads `content` | |
| and declares `pem_certificate`, a stored compute on `content`; Odoo builds its triggers from | |
| transitive dependencies, so writing `content` already recomputes the key. | |
| - a value kept on purpose, one. `purchase`'s line reads the product's display name in each | |
| vendor's context only to recognise whether the description is still a default ("record | |
| product names to avoid resetting custom descriptions"), and a rename would not change it. | |
| So 10 findings, 5 true: an even split, where Β§3.71's parts each added more true than false and | |
| Β§3.73 declined a narrowing that added fewer. It is taken on Β§3.71's other argument, which is | |
| the deciding one here. Every false finding is what the check already says when the same field | |
| is read as an attribute, and keeping `sudo()` silent would not fix any of them, only hide them | |
| behind a spelling. Three of the five are the storedness class that tree facts would settle. | |
| The hand-classified true positives are now 289, and all 289 are still reported. | |
| ### 3.79 A read through mapped(), measured and not taken | |
| **Measured 2026-09-28.** `ondelete-is-a-decision` fires on a Many2one without `ondelete` that | |
| its file reads through, and `_dereferences_of` counts only `<x>.<field>.<attribute>`. Β§3.66 | |
| taught the depends check that `x.mapped("a.b")` reads `x.a.b`, and this check never learned it, | |
| so `self.mapped("partner_id.lang")` was not a read through `partner_id`. It fails its own way | |
| once the parent is deleted: the attribute yields False, and `mapped()` leaves that record's | |
| value out of what it returns. | |
| The read was built, a literal `mapped("<field>.<attribute>")` counted beside the attribute | |
| reads and worded its own way, and measured against the committed artifacts, which the checkers | |
| of Β§3.78 made: | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 102 | 102 | | |
| | OpenSPP | 161 | 161 | | |
| | odoo core | 2,366 | **2,367** | | |
| Nothing disappears, and no other rule moves. The change saw thirteen such reads. OCA has none | |
| through a field the check considers. OpenSPP's four and eight of core's nine are on fields an | |
| attribute read already reports, and a finding names the attribute read when it has one, so | |
| those findings read as they did. | |
| The one it adds is false. `stock.location`'s parent, `location_id`, declares no `ondelete`, and | |
| the read is `children_quants.mapped('location_id.display_name')`, in the error that refuses to | |
| archive a location still holding stock: a quant's location, not a location's parent. The check | |
| places a read by the field's name and not by the model it is read on, and five of the thirteen | |
| reads are another model's field of that name. Besides the quant's, `pos_session` reads the | |
| `move_id` of bank statement lines it has just created, twice, and `sales_team` the `user_id` of | |
| team members, twice. | |
| Attribute reads are placed the same way, and none of this rule's 41 hand readings, 12 on OCA, | |
| 14 on OpenSPP and 15 on core, was false for that reason. Β§3.71 and Β§3.78 took a spelling whose | |
| false findings were classes the check already had for attribute reads. This one adds a single | |
| finding, false, of a class no reading of this rule has met, and nothing true. So it is not | |
| taken, and a test pins that. What would make `mapped()` worth reading is placing each read on | |
| the model it is read on, for both spellings, which is a change of its own and is named here | |
| rather than built. The hand-classified true positives are still 289, and all 289 are still | |
| reported. | |
| ### 3.80 A name assigned records, measured and not taken | |
| **Measured 2026-09-28.** Β§3.71 named the gap with OpenSPP's approval tiers: `pending = | |
| review.tier_review_ids.filtered(...).sorted("sequence")[:1]`, then `pending.tier_id`, reads | |
| nothing past the name, because `_aliases` binds loop variables only. A loop over such a name is | |
| the same gap one step on: `moves = self.filtered(...)`, then `for move in moves:`. | |
| The binding was built. A name is bound when every value the function assigns it resolves to one | |
| path, the test `_held_recordset` already makes for `sudo()`. A value naming the name itself, | |
| `lines = lines.filtered(...)`, keeps the path the name held. A name bound any other way as well, | |
| as a parameter, a loop target, a `with`, an unpacking or a walrus, stays unbound. Measured | |
| against the committed artifacts: | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 102 | **107** | | |
| | OpenSPP | 161 | **169** | | |
| | odoo core | 2,366 | **2,480** | | |
| By part: the depends check adds 5, 7 and 113, the constrains check 0, 1 and 5. Core also loses | |
| four. Two are the event findings Β§3.72 showed the method never reads, the `_origin` paths | |
| through the event type's mail lines: a comprehension over `mails_to_remove` now binds `mail` to | |
| the event's own lines, which are what the lambda filters. The other two are | |
| `depends-must-not-name-unread-fields` stepping aside, once because the body now reads | |
| `commercial_partner_id.is_company` through `p`, which the depends check then reports, and once | |
| because `len(project)` hands a record to a call, as `len(self.project_id)` always did. Five | |
| more findings only moved to an earlier line, which changes no count. | |
| Every addition was read. **OCA, 5, all true**, all smart defaults: `account_check_deposit`'s | |
| currency and `account_spread_cost_revenue`'s template journal and accounts follow the company's | |
| setting, read through a name assigned the record's company. The same reads spelled as | |
| attributes in `account_spread.py` are already classified true. | |
| **OpenSPP, 8: 4 true, 4 false.** Β§3.71's own example is true as nothing rewrites it: the tier | |
| review form leaves `tier_id` editable, and the review's stored current tier keeps the old one. | |
| `spp_vocabulary`'s mapping stores its display name from both codes' `code`, and `write()` | |
| refuses a rename only for a system vocabulary's shared codes. Two findings, one per code. The | |
| fourth is the constrains check: `spp_claim_169` allows one default issuer per company, checks | |
| it when `is_default` is written, and never when a default issuer moves to a company that has | |
| one. The false ones are `spp_registrant_gis`'s `geom = rec.coordinates`, whose `geom.x`, | |
| `geom.y` and `geom.is_empty` are attributes of a geometry and not fields, and a read in | |
| `spp_case_programs` that feeds only the method's non-stored output. | |
| **Core's depends, 113: 37 true, 76 false.** The false ones: | |
| - 27 keep a value on purpose, visible in the code: a compute that fills its field only while it | |
| is empty or the record is new, a journal item's label rebuilt only while it still matches its | |
| product, a lot's dates offset from the product only "when create", a manufacturing order | |
| left on its product while the BoM that changed marks it outdated. | |
| - 17 compute a field another module declares without `store=True`. | |
| - 11 are covered from outside the method: a declared field that is itself a stored compute on | |
| the read. A statement line's `internal_index` embeds its date, and a journal item's `debit` | |
| and `credit` compute from `balance`. | |
| - 11 name a field the model refuses to change or fixes at creation: a picking type's company, a | |
| country's ISO code, a POS order's company. | |
| - 5 are declared by another module's override of the same compute, which Odoo merges. | |
| - 5 are one of a kind. `res.partner.write()` rewrites the commercial partner of every move by | |
| hand, three expense amounts can only change after the reinvoiced line built from them is | |
| zeroed and replaced, and one read sits in a fallback the form never lets run. | |
| Of the 37 true, 28 are smart defaults, 4 read a field nothing in core rewrites, and 5 are a | |
| stored value that goes stale: a stock move's weight when its product's weight is edited, a | |
| task's working-time figures when it moves to another project or its company's calendar changes | |
| (three findings), and an own-expense bill's commercial partner when the report's payment mode | |
| changes. | |
| **Core's constrains, 5: 2 true, 3 false.** `stock.rule` checks that its company matches its | |
| route's, and `stock.route` checks it from its side, each only when its own `company_id` is | |
| written, so a rule moved to another company's route is checked by neither. The other true one is | |
| the GS1 barcode rule Β§3.72 read. Two false ones are covered through a declared stored compute: | |
| Odoo validates the constraints of every field it recomputes (`_compute_field_value`, | |
| `odoo/models.py` at 3c3e3b3), so writing a sale order's company recomputes `warehouse_id` and | |
| runs `_check_warehouse`, and a move's `product_uom` follows its product the same way. The third | |
| names a field `write()` refuses to change on the online payments it covers. | |
| Β§3.71 took each part because each added more true findings than false, and Β§3.78 took an even | |
| split. Core's depends additions read 37 of 113, the ratio Β§3.72 declined for the lambda binding | |
| (40 of 127, 28 of them smart defaults then too), and OCA's five and OpenSPP's three cannot | |
| carry that. The constrains additions read 3 of 6. So the binding is not taken, and a test pins | |
| that. | |
| The new binding never gave a name a path other than the one it holds. Two things were met on the | |
| way that matter when this is revisited: | |
| - **A name can hold a value that is not records.** OpenSPP's `coordinates` is a | |
| `GeoPointField`, declared in the same file, and the declaration would have said so. | |
| - **One map per function.** `_aliases` binds a name once for the whole method. `pos_sale`'s | |
| `_compute_qty_invoiced` iterates `pos_line` over its POS lines and then over their refunds, | |
| so the first read took the second binding. Β§3.72 met the same thing with a lambda's | |
| parameter; binding assigned names reaches it more often. Two of the constrains findings came | |
| in that way too, a lambda parameter sharing a name with a loop over a name now bound, and on | |
| the same records by accident, as Β§3.72 found that spelling usually is. | |
| What would separate true from false is what Β§3.72 found. Facts from outside the file decide 33 | |
| of the 76: storedness, merged decorators, a declared compute that already covers the read. The | |
| keep-once shapes, 27, are mostly visible in the method itself, a guard on the field it computes | |
| or on whether the record is new, and are a narrowing of their own. The hand-classified true | |
| positives are still 289, and all 289 are still reported. | |
| ### 3.81 Advisory gates nothing in the answer layers either | |
| **Decided and measured 2026-10-01.** Β§3.77 left a question about the design open: Β§3.38 says an | |
| advisory finding gates nothing, and the answer layers let one decide. Three answers were on the | |
| table. Advisory decides neither a layer's verdict nor escalation; it escalates but never ends a | |
| run UNVERIFIED; or it keeps deciding and Β§3.38 is amended. The first is taken. It is what Β§3.38 | |
| already says, and it never spends a 30B pass on a preference. | |
| **It reached further than Β§3.77 counted.** Β§3.77 named the domain layer's verdict and | |
| `worth_escalating`. Reading the code for the change found two more: | |
| - `decide` combines the layers by reading their findings, not their verdicts. So the semantic | |
| layer's setting-aside, the one answer-side place Β§3.38's table names, never reached the verdict | |
| printed at the end: `decide` counted the finding the layer had set aside, and would have | |
| printed WARN on a preference with the domain layer fixed. An answer whose only critical | |
| finding was advisory was printed REJECT, which is `nexa ask` exiting 1. | |
| - The answer layers read the rule's flag alone. `scan_source` also reads the grade a trigger | |
| gives one finding: the touch and escape clauses of `sudo-bypasses-record-rules` grade theirs | |
| `unproven` (Β§3.47), and `sql-constraints-are-ignored-from-19` grades a bare snippet `unproven`, | |
| since nothing was walked to say which series it is for (Β§3.62). `check` set those findings | |
| aside in a file, and the answer layers let the same findings decide in an answer, critical | |
| ones included. | |
| **The finding carries the flag now.** `Finding.advisory` is set by one function, | |
| `is_advisory(rule, grade)`, which `scan_source` calls too, so a file and an answer cannot | |
| disagree about which findings count. The domain layer has one finding per rule, and it is | |
| advisory only when every hit behind it is: a `sudo().write()` beside a `sudo().search()` | |
| decides, because the search does. `render` labels an advisory finding "advisory, gates nothing", | |
| and a PASS printed under findings says it found nothing but advisory ones. Telemetry still counts | |
| them under `violated`, because reported everywhere means there too. | |
| | | an advisory finding | | |
| |---|---| | |
| | `check` | its own heading, and out of the exit code | | |
| | `fieldtest` | counted apart from the per-kloc rate | | |
| | the semantic layer | recorded, and the verdict does not follow it | | |
| | the domain layer | recorded, and the verdict does not follow it (new) | | |
| | the decision | no reason for WARN or REJECT (new) | | |
| | escalation | never sends a draft to the 30B (new) | | |
| **The tests' escalation example moved.** `test_pipeline` escalated on `NEGATIVE_QTY`, written | |
| before Β§3.38 graded `prefer-sql-constraint`. It now escalates on the onchange question, recorded | |
| from one `nexa run` on 2026-10-01: the 7B refuses the date in an onchange | |
| (`onchange-is-not-validation`, critical), and the 30B answers with `@api.constrains`. Both | |
| answers are fixtures verbatim. `NEGATIVE_QTY` stays as the test that an advisory draft never | |
| loads the 30B, and each place above has a test of its own. | |
| **Both runs were redone**, since both sets of verdicts carried the effect. `nexa eval --check` | |
| first, the three subjects five times each: | |
| | | 2026-09-27 | 2026-10-01 | | |
| |---|---|---| | |
| | Findings reported about the files | 10, in 3 of 3, and 3 advisory apart | the same | | |
| | The same findings on every run | 3 of 3 files | 3 of 3 files | | |
| | PASS / WARN / REJECT | 5 / 5 / 5 | 4 / 7 / 4 | | |
| | Same verdict every run | 1 of 3 | 0 of 3 | | |
| | Rules the model cited beyond the checkers | 1 | 0 | | |
| The change decides one of the fifteen: an `fsm_job.py` review whose only critical finding was a | |
| `sudo()` call graded `unproven` on its own is WARN, where the old rule printed REJECT. No other | |
| verdict in the rows rests on an advisory finding alone. The rest is the model's own spread at | |
| temperature 0.15. `load_prices.py` gave up its five PASSes to one WARN from the structural | |
| layer, which reads the shape of the answer and not its code, and `fsm_visit.py` drew one REJECT | |
| fewer. `fsm_job.py` shows the same two REJECTs and three WARNs as before only because the change | |
| took one REJECT back. | |
| Then `nexa eval --pipeline`, the nine questions five times each. It took 20 minutes where | |
| Β§3.77's took 32, nine passes on the 30B fewer: | |
| | | 2026-09-27 | 2026-10-01 | | |
| |---|---|---| | |
| | PASS / UNVERIFIED | 38 / 7 | 43 / 2 | | |
| | Same verdict every run | 8 of 9 | 8 of 9 | | |
| | Escalation triggered | 18 of 45 | 9 of 45 | | |
| | Escalation rescued a bad draft | 11 of 18 | 7 of 9 | | |
| The evidence bundles carry each finding's flag now, so every draft can be read against both | |
| rules. Under the old one these 45 drafts escalate 19 times, under this one 9, and the ten that | |
| went are Β§3.77's ten exactly. The negative-quantity question's five runs end PASS, where all | |
| five ended UNVERIFIED, and the positive-amount question no longer counts five rescues of a | |
| preference. Every escalation left is on a critical finding a larger model can act on: the | |
| onchange question five times, all five repaired, and the monetary question four times, two | |
| repaired, each a stored compute reading a field it does not declare. 7 of 9 sits beside the 6 | |
| of 8 Β§3.77 counted without the advisory escalations, and the monetary question's spread is the | |
| difference. | |
| The eval rows still record whether a run escalated and not why, so `figures.py` cannot count | |
| advisory escalations from them (Β§3.77). There are none left to count, and a test pins that. | |
| Nothing here changes a checker. `scan_source` calls the shared function where it computed the | |
| same thing inline, and a rescan of the two OCA checkouts reproduces their artifact hit for hit, | |
| 116 of 116. So the fieldtest artifacts stand, and the hand-classified true positives are 289 of | |
| 289, all still reported. | |
| ### 3.82 A field the constraint cannot name | |
| **Decided and measured 2026-10-01.** `constrains-must-list-every-field-read` asks a constraint to | |
| name every field its body reads, and Odoo will not let it name some of them. A field that is | |
| neither stored nor inversible is never among the fields a write hands to `_validate_fields`, so a | |
| constraint naming it never runs for it, and `_constraint_methods` logs "@constrains parameter ... | |
| is not writeable" and does nothing more (odoo/models.py, 18.0 at 3c3e3b3). Β§3.71 met one such | |
| finding, Β§3.72 counted eleven, and the README's OpenSPP reading carried one as false: the gap was | |
| real, and the fix it named could not be applied. | |
| The choice was between exempting those findings and asking for what the field is computed from. | |
| Exempting them all would have hidden every real gap among them, so the finding asks for the | |
| inputs, and only a constraint that already names every input is left alone. | |
| **Read from the declaration.** The checker reads the fields the constraint's own class declares | |
| (`_class_fields`). Whether one can be named is Odoo's own test, three-valued (`_writeable`): a | |
| computed field is unstored unless `store=True` and inversible only with an `inverse=`, and a | |
| related field is unstored and readonly unless it says otherwise and gets an inverse only when it | |
| is not readonly (`_get_attrs` and `setup_related`, odoo/fields.py at 3c3e3b3). A field declared | |
| elsewhere, or declared with anything but a literal, is unknown and changes nothing. What can | |
| trigger the check is a write of what the field is computed from: the roots of its compute's | |
| `@api.depends`, or the first field of its related path, followed through any of those that | |
| cannot be named either (`_unwriteable_inputs`). A dotted dependency is reached through its first | |
| field, as a dotted read always was, since `@api.constrains` names fields of its own model only. | |
| When the compute is not in the class, or declares nothing a static pass can read, the finding | |
| stays and says the field cannot be named, rather than asking for it. | |
| **Measured.** Each tree reproduced its artifact hit for hit with the committed checkers first, | |
| core from a clean export of HEAD. Then: | |
| | | before | after | | |
| |---|---|---| | |
| | OCA | 102 | 102 | | |
| | OpenSPP | 161 | 161 | | |
| | odoo core | 2,366 | **2,358** | | |
| Core loses eight, each a constraint that names every input of the field it reads: an invoice's | |
| `tax_country_id`, an event's and an event ticket's `seats_available`, a task's `subtask_count`, an | |
| orderpoint's `product_uom`, a quiz question's `correct_answer_id`, a product image's | |
| `embed_code`, and an account report value's `report_country_id`. That last one is related | |
| through `target_report_line_id`, itself related through the `target_report_expression_id` the | |
| check names. | |
| Ten more on core and three on OpenSPP stay, each now told the input it is missing instead of a | |
| field it cannot name. A leave's mandatory-day check names its dates and reads `has_mandatory_day`, | |
| computed from them and from `holiday_status_id`, so a leave moved to another type is not checked | |
| again. The POS config's two currency checks read a currency computed from `company_id` and | |
| `journal_id`. The payment capture wizard's three reads come from `transaction_ids`, the refund | |
| wizard's from `payment_id`, and the Indian withholding wizard's from `withhold_id`. The activity | |
| scheduler's `error` is computed from seven fields and its check names six, leaving out | |
| `plan_available_ids`, a stored compute, which can be named. One is unknown: | |
| `account.withholding.line` leaves `_compute_comodel_currency_id` to the models that inherit it, | |
| and the finding says `comodel_currency_id` cannot be named. On OpenSPP, `spp_case_base`'s stage | |
| check reads `has_active_plan` and is told to name `intervention_plan_ids`, and `spp_drims`'s | |
| delivery wizard reads two fields computed from `move_id`. | |
| **One verdict moved.** `has_active_plan` was the OpenSPP reading's one `advice-cannot-be-taken`, | |
| false because the fix it named could not be applied. The same method's read of | |
| `intensity_level` was read true, a stage check that does not run again when what it checks | |
| changes, and with its advice now one that can be taken this finding reads the same. The two rules | |
| read 88 true, 14 false and 3 undecidable on OpenSPP, 86% where they read 85%, and the | |
| hand-classified true positives are 290, all still reported. One core reading leaves the count: | |
| `event`'s sold-out message, false and unreported since Β§3.58, which `figures.py` had matched to the | |
| `seats_available` finding three lines above it. With that finding exempt the row matches | |
| nothing, and core's read findings are 88 where they were 89. | |
| **Against the count of 2026-09-28**, which found 23 (8 to exempt, 14 missing an input, 1 unknown) | |
| where there are 21 now (8, 12, 1): that count did not ask about `inverse=`. | |
| `mrp.production`'s `move_byproduct_ids` and `ir.ui.view`'s `arch` have one, so Odoo runs a | |
| constraint naming them, and both stay findings asking for the field, as before. | |
| `report_country_id` moved from missing an input to exempt by following its related input through, | |
| and `plan_available_ids`, which that count took for non-stored, is stored. | |
| **Not added.** A constraint that names an unwriteable field of its own class, and so never runs | |
| for it, is a shape none of the three trees has, so nothing is added for it. Each behavior above | |
| has a test: covered, one input missing, stored or inversible as before, related through its | |
| first field with and without `readonly=False`, followed through an unstored input, and the | |
| compute that is not in the class. | |
| ### 3.83 Facts from the tree for the depends check | |
| **Built and measured 2026-10-01.** Β§3.72 proposed it, and Β§3.71, Β§3.78 and Β§3.80 kept counting | |
| what it would decide: findings of `stored-compute-needs-complete-depends` that are false for a | |
| reason no single file shows. Three of those reasons are facts about declarations, and the tree a | |
| fieldtest walks holds every declaration: | |
| - **A, a compute nobody stores.** One whose field another module declares without `store=True` is | |
| outside the rule, as one declared so in its own file always was. Β§3.78's three `pos_sale` | |
| overrides of `sale`'s amounts were this, and 17 of Β§3.80's 76. | |
| - **B, merged depends.** Odoo merges `@api.depends` from every definition of the method in the | |
| model's MRO (`get_depends` and `resolve_mro`, odoo/fields.py at 3c3e3b3), so a dependency | |
| another definition declares is declared. Β§3.71's "depends merged from `sale_project`" was this, | |
| and 5 of Β§3.80's. | |
| - **C, transitive triggers.** Every field with dependencies, stored or not, feeds the trigger | |
| table, every prefix of a dotted dependency counts as a dependency of its own, and the trigger | |
| tree is the transitive closure of both (`resolve_depends` in odoo/fields.py, `_field_triggers` | |
| and `get_field_trigger_tree` in odoo/modules/registry.py, all at 3c3e3b3). So a declared field | |
| that is computed or related covers what it depends on in turn. Β§3.78's certificate key was | |
| this, and 11 of Β§3.80's. | |
| What `write()` refuses stays undecidable from declarations, as Β§3.72 said, and nothing here tries it. | |
| **What the walk records.** `context.tree_facts` now reads every Python file's ORM declarations | |
| (`semantics.orm_declarations`): per class, the model (its `_name`, or a single `_inherit`, as | |
| Odoo names it), the models it inherits, each field's compute, related path, store, inverse and | |
| comodel, and each method's literal `@api.depends`. Every declaration keeps the module it ships | |
| in, the directory holding its manifest, and every manifest's `depends` is read, so the checker | |
| can ask which modules are installed whenever the one in front of it is (`context.module_closure`). | |
| fieldtest hands each file the facts with its module (`context.for_file`), and `nexa check` walks | |
| the module it was given, as before. A file over 200,000 characters is read whole for this: | |
| `account_move.py`, `account_tax.py` and `mail_thread.py` are longer and would not parse cut | |
| short. The older facts keep the limit they had, so nothing they decide moves. | |
| **Each answer only where it holds.** A needs a complete walk that names the model and everything it | |
| inherits with `_name`, since "no declaration stores it" is true only of everything read. One | |
| `store=True` anywhere keeps a field stored, and `compute=` without `store=True` leaves it | |
| unstored whatever an earlier declaration said (`_get_attrs`). `nexa check` walks one module and | |
| never applies A. B takes only the definitions in the module itself and in the modules it depends | |
| on, transitively: a sibling that overrides the same compute is not always installed with it. C | |
| searches from the declared paths toward the read. A step replaces the rest of a path with what | |
| the field there depends on and keeps the prefix that reached it, so a step where the path has | |
| already left the read's can never come back to it, and is not taken. Every path met is then a | |
| prefix of the read followed by one dependency some field declares, so the search ends without a | |
| limit. The first version walked forward with a limit of 400 paths, reached it on core, and gave | |
| different findings under different hash seeds. The search toward the read gives the same | |
| findings every run, checked under three seeds. | |
| **Measured.** The scans of the three trees and a rerun of the checker on every file with a depends | |
| finding, once with no part, once with each part alone and once with all three, agree finding | |
| for finding. With no part, the rerun reproduces the artifact's depends findings exactly. | |
| | | before | after | A alone removes | B alone | C alone | | |
| |---|---|---|---|---|---| | |
| | OCA | 102 | **101** | 0 | 0 | 1 | | |
| | OpenSPP | 161 | **158** | 0 | 2 | 1 | | |
| | odoo core | 2,358 | **2,207** | 50 | 16 | 96 | | |
| On core the parts overlap: 42 findings only A removes, 11 only B, 86 only C, 11 more that two or | |
| three of them remove alone, and one that B and C remove only together, where B brings in a | |
| journal the parent definition declares and C follows it to its company. Nothing is added. | |
| **Every removal was explained and checked against the code.** The evidence for each of core's | |
| 151 was printed from the same facts: for A, every declaration of the computed fields with its | |
| module and store; for B, the definitions whose depends cover the read and their modules; for C, | |
| one chain from a declared path to the read. All 151 have one, and the four on OCA and OpenSPP | |
| were read in full. The A fields are values no module in core stores, `account.move`'s | |
| `needed_terms` and `show_reset_to_draft_button`, an employee's `hr_presence_state`, `sale`'s | |
| `amount_to_invoice` under `pos_sale`. Each B merge comes | |
| from a module the reader's depends on, `purchase` under `purchase_stock`, `sale_margin` under | |
| `sale_stock_margin`, `point_of_sale` under `pos_sale`. The C chains are Odoo's own, a journal | |
| item's currency following its move's, a timesheet's cost following its employee's | |
| `hourly_cost`, a task's project following its parent's. The longest is mrp's: a production's | |
| `state` depends on `move_finished_ids.state`, `move_finished_ids` on `date_finished`, that on | |
| `workorder_ids.duration_expected`, and that on the work order's `operation_id`, so a change of | |
| operation marks the production for recompute through four declared hops. Those were read in the | |
| source, with a spread of the others. | |
| **Three readings were wrong.** No core finding that went had been hand-classified, true or false. | |
| Three elsewhere had been read true, and the facts that removed them show they were false: | |
| - OCA's `account_spread_cost_revenue`: `_compute_all_posted` reads `currency_id` and declares | |
| `unposted_amount`, which `_compute_amounts` computes with `currency_id` among its depends. A | |
| change of currency recomputes `unposted_amount` and then `all_posted`. The same line's read of | |
| `currency_id.rounding` is still reported and still true: nothing declares the rounding. | |
| - OpenSPP's `spp_cel_event`: the override of `_compute_cel_expression` reads `source_type` and | |
| `aggregate_target`, which `spp_cel_domain`'s definition declares, and `spp_cel_event` depends on | |
| `spp_cel_domain`. Odoo merges the two lists. | |
| All three were read before Β§3.59 found the merge and before anyone asked about transitive | |
| triggers. Each row says so and why, and keeps its place: OCA's two flagship rules read 98% and | |
| 100% where they read 100% and 100%, OpenSPP's 84% where it read 86%. The hand-classified true | |
| positives are 287, and all 287 are still reported. | |
| **One fault in `figures.py` came out on the way.** It matched a classification row to a finding | |
| by rule, path and line alone, so two findings on one line were interchangeable, and once the | |
| `currency_id` finding went, its row could take the `currency_id.rounding` finding and leave that | |
| true row reading lost. A row that carries its finding's words is now matched to a finding with | |
| those words first, and line alone decides only what is left. Run over the committed artifacts | |
| it changes nothing: 289 of 289, the same four re-anchored. | |
| **Left.** `nexa check` walks only the module it is given, so a merge or a chain through another | |
| module is invisible to it: `spp_cel_event`'s two findings still show there. Walking the closure | |
| of the module's manifest, where the dependency modules sit beside it, would bring B and C to | |
| `check`; it was not built here, and Β§3.84 builds it. And Β§3.72's lambda binding and Β§3.80's name binding were declined on | |
| classes this decides in part, so both are worth measuring again on top of it. Β§3.87 measures | |
| them. | |
| ### 3.84 `check` reads the modules its module depends on | |
| **Built and measured 2026-10-01.** Β§3.83 left `nexa check` walking only the module it is given, so | |
| the merged `@api.depends` (B) and the transitive triggers (C) it brought to the depends check could | |
| not reach a definition or a field in another module. `spp_cel_event`'s two findings, which | |
| fieldtest no longer reports, still showed there. | |
| **What it reads now.** `module_facts` also reads every module the file's manifest depends on, | |
| transitively, that sits beside its module, as the modules of an addons directory do | |
| (`_dependency_sources`). Each of them is installed whenever the module is, which is the test B and | |
| C already apply. They are read for their ORM declarations and their manifests' `depends` alone | |
| (`tree_facts` takes them as `dependencies`), and test directories are 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, which needs every declaration, still never applies in `check`. A dependency | |
| installed anywhere else is not looked for: Odoo core keeps `base` apart from its other modules, | |
| and OCA's modules depend mostly on core's. | |
| **Sized first.** Before anything was written, the closures were counted on the three trees. OCA's | |
| reach 18 Python files, since its modules depend mostly on core. OpenSPP's reach 714. Core's reach | |
| 1,044, and 445 of its 656 modules depend on more than 400 files, the cap the module walk itself | |
| has. The read is capped at 1,000 Python files, about two seconds of parsing, past which `check` | |
| reads nothing beyond the module and has the facts it had before. In the three trees that is one | |
| module, core's `test_event_full`. Parsing is most of the cost, and `orm_declarations` now parses | |
| without the compile step `parse_python` adds, whose errors it never read: the same records, | |
| measured over two closures of core, in about three quarters of the time. | |
| | `nexa check --no-model`, median of five runs | before | after | | |
| |---|---|---| | |
| | `spp_cel_event`'s aggregation model, 183 files beside | 0.55 s | 0.79 s | | |
| | `sale_stock`'s account move, 598 files beside | 0.85 s | 2.11 s | | |
| | `website_sale_collect`'s payment transaction, 823 files beside | 0.32 s | 1.87 s | | |
| | OCA `auditlog`'s autovacuum, nothing beside | 0.40 s | 0.40 s | | |
| **Measured.** `check` was run on every file fieldtest reviews in the three trees, through the call | |
| `check` makes (`module_facts`, then `scan_source`), from clean exports of the code before and | |
| after. Each module's facts were computed once and handed to its files with `for_file`, which is | |
| what `check` computes for each of them. | |
| | hits `check` reports, suppressed and advisory included | before | after | | |
| |---|---|---| | |
| | OCA | 116 | 116 | | |
| | OpenSPP | 519 | **517** | | |
| | odoo core | 2,810 | **2,773** | | |
| Nothing is added, and nothing moves for another rule. OpenSPP's two are `spp_cel_event`'s. Core's | |
| 37 are in 22 modules, each reading through a module it depends on. A merged definition alone | |
| answers 8, a chain of triggers alone 9, either of them 15, and the two only together 5. | |
| `sale_stock`'s delivered quantity reads the method `sale`'s definition declares, | |
| `l10n_latam_check`'s destination account reads the partner `account`'s declares, and | |
| `sale_stock_margin`'s purchase price reads three fields `sale_margin`'s definition declares; nine | |
| localizations make up 14 of them. Each is one of the 151 Β§3.83 removed and explained. | |
| **Against fieldtest.** On OCA and OpenSPP, `check` and fieldtest now report the same depends | |
| findings. On core 38 differ, all in one direction and all one class: a compute whose field no | |
| declaration in the tree stores, which only a walk of the whole tree can say (A). Run again with | |
| the whole tree's facts marked incomplete, each of the 38 comes back. Of the 151 findings Β§3.83 | |
| removed on core, `check` drops the other 113, 37 of them since this change. The other rules still | |
| differ between the two where their facts need the whole tree, record rules and deletes, as they | |
| did before this. | |
| **fieldtest is unchanged.** It passes no dependencies, and the parse gives the same records. A | |
| rescan of the three trees reproduces each artifact hit for hit. Each behavior above has a test: a | |
| dependency beside merged, a sibling it does not depend on left unread, a dependency's dependencies | |
| read and its tests not, the cap, and a dependency elsewhere or a name that is not a module name | |
| not followed. | |
| **Left.** A module whose dependencies are installed elsewhere, as OCA's on core usually are, gets | |
| none of them; reading an addons path a person names would reach them. And the read is made for | |
| every file, while only a file with a depends finding left can use it: reading on demand would | |
| spare the seconds on most runs. Β§3.86 builds both. | |
| ### 3.85 The cross-hit row, read from the probes' rows | |
| **Built 2026-10-01.** 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. Reading the two commands to give crosscheck an artifact of its own showed the rows | |
| were already committed. 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. Its rows are crosscheck's list with a class beside each. | |
| So there is no second artifact. What a cross-hit is has one definition now, `layers.cross_hits`, | |
| which crosscheck lists and the probe classifies, so the two cannot drift apart. `figures.py` | |
| counts the probe's contradiction rows as `adversarial.crosshits`, and the row is marked. CI's | |
| corpus job already re-runs the probes against their committed rows, so a pattern that starts to | |
| fire on another rule's correct sample fails CI until the rows are rewritten, and the README's | |
| count moves with them. crosscheck prints what it printed before, byte for byte, the probes | |
| reproduce their artifact, and a test plants a cross-hit and finds it in both. | |
| ### 3.86 The modules a module depends on, read when asked and where named | |
| **Built and measured 2026-10-01.** Β§3.84 left two things. `check` read the modules beside the one | |
| it was given for every file, while only a file with a depends finding left could use them, and it | |
| looked for them beside the module only, so an OCA module, whose dependencies are core's and sit in | |
| another directory, got none of them. | |
| **Read when asked.** `module_facts` no longer reads the dependency modules itself. It 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 can only cover more, so the reads already covered need no second look and the | |
| findings are the eager read's exactly. The loader runs once per module, adds the dependency files' | |
| declarations to what the module walk already read (`_with_declarations`, from the `orm_inputs` the | |
| facts now keep), and reads nothing of the module twice. fieldtest's facts carry no loader: its walk | |
| had the whole tree already. | |
| **Where named.** `nexa check --addons-path <dir>[,<dir>...]` names where else the dependencies are | |
| installed, spelt and written as Odoo's own option is. A dependency is looked for beside the module | |
| first, then in each named directory in turn, the first holding it winning, as Odoo's own does. A | |
| named directory that is not there is a bad argument, exit 2, since the modules it was meant to | |
| reach would otherwise go unread without a word. Sized first: with core's two addons directories | |
| named, OCA's closures reach 680 Python files, under the cap of 1,000. Both trees are 18.0, as the | |
| core checkout is; OpenSPP is 19.0, and no core here is its own. | |
| **Measured.** `check` was run, as Β§3.84 ran it, on every file fieldtest reviews in the three trees, | |
| from clean exports of the code with the eager read and with the read on demand. The two report the | |
| same findings on all three, hit for hit. The dependency modules are asked for in 13 of OCA's 98 | |
| modules, 23 of OpenSPP's 131 and 121 of core's 657; every other module never reads them. | |
| | `nexa check --no-model`, median of five runs | read for every file | read when asked | | |
| |---|---|---| | |
| | `spp_cel_event`'s aggregation model, which needs them | 0.93 s | 0.93 s | | |
| | `sale_stock`'s account move, which needs them | 2.56 s | 2.79 s | | |
| | `website_sale_collect`'s payment transaction, which does not | 2.10 s | 0.35 s | | |
| | OCA `auditlog`'s autovacuum, nothing beside | 0.41 s | 0.42 s | | |
| `nexa help check` said `--no-model` runs in under a second and gives the list fieldtest gives. It | |
| says usually under a second now, a second or two more when a depends finding needs the modules | |
| beside, and the list fieldtest gives but for a finding only a walk of the whole tree can rule out | |
| (Β§3.84). | |
| **OCA with core named.** Run with core's addons directories as its addons path, `check` on OCA | |
| reports one finding fewer, 116 hits to 115, and adds none. `account_cash_deposit`'s | |
| `_compute_is_reconcile` declares `move_id.line_ids.reconciled` and reads each line's `account_id`. | |
| `reconciled` is the journal item's stored compute `_compute_amount_residual` in core's `account`, | |
| which depends on `account_id` (odoo/addons/account/models/account_move_line.py at 3c3e3b3), and | |
| Odoo's triggers are transitive, so a change of a line's account recomputes `reconciled` and then | |
| `is_reconcile`. The finding had been read true, before transitive triggers were asked about and | |
| with core out of reach. It is re-read false, class `covered-transitively`, and keeps its place. | |
| fieldtest of OCA still reports it, since it walks only the roots it is given, which is what the row | |
| says. OCA's flagship depends rule reads 95% where it read 98%, OCA's read findings are 84 true and | |
| 7 false, and the hand-classified true positives are 286, all still reported. | |
| Each behavior has a test: no read for a file that needs none and one read for a module that does, | |
| a dependency in a named directory read, the module beside it preferred, the option's parsing, and a | |
| named directory that is not there refused. | |
| ### 3.87 The two bindings, measured again on the tree facts | |
| **Measured 2026-10-01.** Β§3.72 declined the lambda's binding and Β§3.80 the name assigned records, | |
| each because what it added 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, so both were measured | |
| again on top of them. 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's table exactly: | |
| OCA 102 to 107, OpenSPP 161 to 169, core 2,366 to 2,480, with the same four findings gone and the | |
| same five moved. The lambda binding on fed0255 reproduces Β§3.72's: OCA +8, OpenSPP +1, core +127 | |
| depends and +27 constrains, and the same two event findings gone. So the rebuilds are the bindings | |
| that were read. | |
| On today's tree, against the committed artifacts: | |
| | | name binding | lambda binding | | |
| |---|---|---| | |
| | OCA | 101 to 106 | 101 to 108 | | |
| | OpenSPP | 158 to 166 | 158 to 159 | | |
| | odoo core, depends | +86, was +113 | +89, was +127 | | |
| | odoo core, constrains | +5, the same five | +25, was +27 | | |
| **Neither needed reading again to decide.** The tree facts only remove findings, and every addition | |
| today is one that was read then, but two. So the readings bound what is true now: | |
| - The name binding's depends findings are all among Β§3.80's, of which core's 113 read 37 true, | |
| OCA's five all true, and OpenSPP's seven three true. At most 45 of today's 98 are true, and at | |
| least 53 false. Its constrains findings are the same six, three true and three false, as Β§3.80 | |
| found. | |
| - The lambda binding's depends findings on core are 88 of Β§3.72's 127, which read 40 true, and one | |
| more, `hr_expense`'s own-expense bill reading the report's payment mode, the finding Β§3.80 read | |
| true through a name. With OCA's seven of Β§3.72's eight, two of them true, and OpenSPP's one, | |
| true, at most 44 of 97 are true and at least 53 false. On constrains, 24 of 25 are among Β§3.72's | |
| 27, which read 9 true; the 25th is one Β§3.82 re-worded, `l10n_latam_invoice_document`'s document | |
| check now asked for the journal behind a field it cannot name. Counted true, that is at most 10 | |
| of 25. | |
| So both stay as they were, not taken, and the tests that pin them stand. | |
| **What the facts decided.** Of the name binding's 113 core findings, the facts take 27: a compute | |
| nobody stores (A) alone 15, a declared field covering the read (C) alone 5, a merged decorator (B) | |
| alone 3, and 4 that more than one of them decides. Β§3.80 had counted 33 in those three classes. | |
| The lambda binding's 127 were 121 by 0539253, since Β§3.72's own exemption for a method's output took | |
| nine and later reads found three. The facts take 32 of the 121: A alone 14, C alone 9, B alone 3, | |
| B and C only together 2, and 4 that more than one decides. Each part was switched off in turn on | |
| each finding to say which decides it. | |
| **What stands between them and the bar** is what Β§3.72 and Β§3.80 said no declaration decides. For | |
| the name binding, the 27 keep-once shapes: a compute that fills its field only while it is empty | |
| or the record is new, visible in the method. For the lambda binding, the 12 guards on which | |
| records to touch, and the 28 identifiers nothing changes after creation. A narrowing for the | |
| guards would be the next measurement, and the old readings put each binding near the bar with it; | |
| those are estimates from readings made before the facts, so it would be measured, not assumed. It | |
| is not built. | |
| ## 4. Performance traps, all found by measurement | |
| Within four instrumented runs the report showed the 7B at ~10 tok/s against a | |
| benchmarked 36. Three diagnoses were offered; **the first two were wrong**. | |
| ### 4.1 `num_gpu: 99` β the actual cause | |
| Ollama's memory estimator is conservative and leaves layers on the CPU with VRAM still | |
| free. **11.7 tok/s automatic versus 38.6 tok/s forced**, same model, same context. | |
| The Modelfiles carry `PARAMETER num_gpu 99`, but the pipeline calls the **raw base | |
| model**, which has no such parameter. Both call paths now pass it explicitly. The 30B | |
| does *not* get it β it genuinely cannot fit and needs a real split. | |
| ### 4.2 KV cache environment variables must precede the server | |
| `OLLAMA_KV_CACHE_TYPE=q8_0` and `OLLAMA_FLASH_ATTENTION=1` were set as user environment | |
| variables while the server was already running. They never took effect. 5.4 GB β 5.1 GB | |
| once actually applied. | |
| **Set them before starting the server, or restart it afterwards.** Setting them in your | |
| shell does nothing for a server already running. | |
| ### 4.3 Embeddings belong on the CPU | |
| 274 MB is trivial except on a 6 GB card, where it is 274 MB the generator needs. Query | |
| embedding now passes `num_gpu: 0`. Bulk indexing still uses the GPU, where nothing | |
| competes. | |
| ### 4.4 Orphaned `llama-server` processes | |
| Killing `ollama*` orphans its `llama-server` children, which keep their GPU allocations. | |
| Two were found holding 1.4 GB of VRAM and 10 GB of RAM. `nexa doctor` checks for these. | |
| **The general lesson:** the 36 tok/s figure was benchmarked once and then quietly untrue | |
| for most of a working session. An unmeasured number decays into a claim. | |
| --- | |
| ## 5. Observed model behaviour | |
| Recorded because it is what a 7B actually does, not what a model card suggests. | |
| - Left alone it wrote **Pydantic v1** syntax (`@validator`) and deprecated | |
| `datetime.utcnow`. | |
| - It **followed the user's stated approach over the system prompt** β asked "I was going | |
| to use an onchange, show me how", it wrote the onchange. An explicit override rule was | |
| needed: *when the user's approach will not hold the invariant, your first line says so.* | |
| - It **regressed under prompt hardening**: fixing the `false`-for-empty scalar handling | |
| broke the Many2one tuple it had previously got right. | |
| - It **cited a real rule while violating it**. | |
| - It **copied instruction text into its output** when the format template looked like | |
| something to emit (`<code block>` appeared literally). | |
| The 30B is better at caveats and trade-offs and not uniformly better at mechanics: on | |
| one question it produced a CHECK constraint containing a subquery β invalid in | |
| PostgreSQL β while correctly identifying reserved stock and concurrent transactions as | |
| the real difficulty. | |
| --- | |
| ## 6. Limitations | |
| ### Model capability | |
| A 7Bβ30B local model does not match a frontier model on hard architectural reasoning. | |
| That gap is a function of scale. Nexa narrows the *validation* gap by grounding answers | |
| in a corpus; it does not close the reasoning gap. | |
| ### Checking is partial, and the layers say where | |
| - 1 of <!--fig:corpus.rules-->28<!--/fig--> rules has no automated check at all. It returns **no verdict**, never a | |
| false pass, and `nexa coverage` prints the reason. | |
| - <!--fig:corpus.by_pattern-->8<!--/fig--> of the | |
| <!--fig:corpus.checkable-->27<!--/fig--> checked rules are decided by a **regex over | |
| generated code**. That catches textual signatures, not meaning. Code wrong in a way | |
| no pattern describes passes silently. The other <!--fig:corpus.by_ast-->19<!--/fig--> | |
| are decided by an AST pass (Β§3.12, Β§3.47, Β§3.54, Β§3.70). The regexes | |
| no longer see docstrings, which are emptied before matching; they still see every | |
| other string literal, so a violation quoted inside a triple-quoted SQL string or a | |
| help text would still be reported. | |
| - The value trace behind four of those AST checkers (Β§3.47) reads **one file and one | |
| function**. It follows no import and infers no type. A float that arrives as a | |
| parameter is still reported as a float comparison; an integer returned by a helper | |
| in another module is too. A traversal or a money string that reaches `os.path.join` | |
| or `float()` through a helper in another file is not reported at all. | |
| - Semantic analysis is **Python only**, which is the whole subject now. An answer may | |
| still contain a JavaScript block, Odoo's web client being OWL; that block gets | |
| delimiter balance and nothing deeper, and the layer reports UNKNOWN rather than | |
| PASS for it. | |
| - The empirical layer resolves imports against this interpreter and assembles | |
| bytecode. It does not execute anything unless `NEXA_EMPIRICAL_EXEC=1`, and even then | |
| only for snippets importing nothing outside a short allowlist β which in practice | |
| means no Odoo answer is ever executed, because they all import `odoo`. A subprocess | |
| with a timeout reduces blast radius; it is **not a security boundary**. | |
| - The context layer is only as good as the file it was given. `nexa ask` has no file, | |
| so it reports UNKNOWN; `nexa check <file>` reads the manifest, the model names, | |
| `ir.model.access.csv` and the record rules in XML, and can then say something real. | |
| - Every Odoo rule declares `odoo_versions: >=16.0`, so the context layer has a | |
| question it can answer about a module on any series and reports per-rule when one | |
| does not apply. `corpus/check_sources.py` supports that range from the | |
| documentation for 44 of the 45 versioned rule-series, and `--source` supports all | |
| 54 rule-series from odoo/odoo itself, `float-comparison-needs-float-utils` at 16.0 | |
| included. Neither is a test run: a page describing an API and a module defining it | |
| both say the construct was there, not that it behaved identically. | |
| - `PASS` means *the checks that ran found nothing*. It does not mean correct, and it | |
| is printed with that sentence attached. | |
| ### The checkers themselves are only as tested as the probes | |
| `nexa adversarial` measures recall under meaning-preserving rewrite, whether a | |
| `satisfied_pattern` can be cleared by a comment, and whether two rules disagree. All | |
| three currently pass. None of them proves a pattern behaves on a real codebase: the | |
| mutations are formatting-level, so an equivalent algorithm written differently is not | |
| generated, and the samples are still small and still written alongside the patterns. | |
| `nexa fieldtest` (Β§3.18) closes part of that gap and opens an honest number in its | |
| place: **<!--fig:oca.rate-->3.0<!--/fig--> hits per 1000 lines** on | |
| <!--fig:oca.lines-->33,960<!--/fig--> lines of reviewed OCA code, down from 11.3 when | |
| this started. It was 2.9 after Β§3.43, and has moved since as rules were composed in and | |
| narrowed; Β§3.53 is why this sentence can no longer fall behind it. | |
| <!--fig:oca.read-->98<!--/fig--> of today's <!--fig:oca.findings-->101<!--/fig--> | |
| findings have been read by hand, which makes it a false-positive rate rather than a hit | |
| rate; the other <!--fig:oca.unread-->3<!--/fig--> arrived after the last reading. The | |
| table below is the reading of 2026-09-15, done against the run in Β§3.42 and re-run | |
| after Β§3.43 narrowed four checkers against what it found. It replaces a count of 117 true, 6 | |
| false and 14 undecidable, which was of a different 135-hit set produced by | |
| materially different checkers β the last paragraph here says why the two numbers | |
| cannot be put in the same column. | |
| | | classified | after Β§3.43 | | |
| |---|---|---| | |
| | True positives | 79 | **79** | | |
| | False positives | 24 | **8** | | |
| | Undecidable from one file | 13 | 13 | | |
| **Every true positive survived the narrowing.** That is the number to check when a | |
| checker is narrowed, and the reason the classification is kept as a file rather than | |
| as three totals. | |
| **Seventy-nine findings in reviewed, merged OCA code** β 2.3 per 1000 lines of | |
| things a maintainer accepted and a checker still has a case about. Forty-one are | |
| stored computes reading a path their `@api.depends` does not declare, each one a | |
| column that goes quietly stale; twelve are `Many2one`s left on the `set null` | |
| default and then dereferenced; fourteen are relations to `account.account` or | |
| `account.journal` from a company-owned model with no `check_company`, several | |
| carrying only a UI `domain`, which constrains a dropdown and enforces nothing. | |
| Seven are `@api.constrains` that validate a field they never declared β writing | |
| that field alone skips the check, which is the defect the rule exists for. | |
| The thirteen undecidable are computes whose field is declared in another module, | |
| where storedness cannot be read from the file in front of the checker. | |
| The twenty-four fell into seven named classes. Naming them was the point: each one | |
| was a checker change rather than an exclusion list, and Β§3.43 took the four that are | |
| a single predicate on evidence already in the file. The last column is what is left. | |
| | Class | Was | What the checker missed | Now | | |
| |---|---|---|---| | |
| | `TransientModel` | 7 | A wizard record is vacuumed. "Written once, survives restarts, diverges from reality" is about persisted data. | **0** | | |
| | `related=` mirror | 4 | The enforcement belongs on the source field, which is separately flagged β so this was also double counting. | **0** | | |
| | Registry model | 4 | `sudo()` on `ir.model`, which has no `company_id` and so no tenant boundary to cross. | 4 | | |
| | Non-stored compute | 3 | No database column exists, so `ondelete` and `check_company` have nothing to act on. | **0** | | |
| | Wrong construct | 3 | A `parent_id` pointing at a *different* model; `==` on an `Integer`; a commit on a deliberately separate cursor. | 2 | | |
| | Advice that cannot be taken | 2 | A table or column name cannot be passed as a query parameter. Only the values can, and both sites already do. | 2 | | |
| | Message-only read | 1 | `_message_only_attrs` excluded a field read inside a `raise`. This one builds the message into a variable first. | **0** | | |
| Every row of that classification is in `artifacts/classification-oca-2026-09.json`, with the | |
| verdict, the class and the reason on each one β so the next person diffs it instead | |
| of re-reading the tree, and a count that has gone stale says so. That file is what | |
| the previous classification did not have, and the reason it could sit here for months | |
| describing a hit set that had moved underneath it. It is also what made Β§3.43 | |
| checkable: narrowing four checkers is only safe if you can show the 79 are still 79. | |
| The four that went were a single predicate each on evidence already in the file β | |
| is the class a `TransientModel`, is the field `related=`, does it have a stored | |
| column, was the read only ever a message argument. The same shape as the | |
| `required=True` exemption and the read-through test that took | |
| `ondelete-is-a-decision` from 198 hits to 16: narrowing by a fact the checker can | |
| already see, rather than by a list of names it has to be told. | |
| The eight that remain are not that shape, and are left rather than engineered away: | |
| - **Registry model (4).** `sudo-bypasses-record-rules` is a regex, and `self.sudo()` | |
| inside `ir_model.py` cannot be recognised by one β it needs the class's `_name`. | |
| That is the same regex-to-AST move `check-company-on-relations` made, on the | |
| largest rule in the corpus by volume: 128 hits on OpenSPP, none of them classified. | |
| Doing it without that classification would be changing the loudest checker here on | |
| the strength of four findings. | |
| - **Wrong construct (2).** A `parent_id` whose comodel is a different model, and `==` | |
| on an `Integer`. Both need the declaration's type, and both rules are regexes. Same | |
| migration, much smaller stakes. The `parent_id` half made it in Β§3.54, and took a | |
| larger class of false positives with it. | |
| - **Advice that cannot be taken (2).** Deliberately not narrowed. Exempting an | |
| interpolated *identifier* from `sql-needs-query-parameters` would mean deciding | |
| from the shape of an f-string which holes are table names and which are values, and | |
| getting that wrong hides an injection. This is the one checker here where a | |
| narrowing could conceal the thing it exists to find, so it keeps its two. | |
| **Neither 24 nor 8 is the 6 reported previously, and none of them compare.** Three | |
| of these rules had been reimplemented since that count β `check-company-on-relations` | |
| and `ondelete-is-a-decision` both moved from a regex to an AST pass, and the SQL, | |
| commit-in-loop and recursion rules did not exist β so it was a different hit set | |
| judged by different checkers. The 2026-09-15 pass was also stricter: it counted a | |
| finding against a wizard or a column-less field as a false positive where the earlier | |
| one did not, which is most of the distance between 6 and 24. | |
| The honest summary is that this rate has never been measured twice the same way, and | |
| that the 8 is the first one standing on a classification anybody can re-open. | |
| It is still two repositories in one domain, and that has not changed. | |
| And it is still only half the question. Β§3.25 measures the other half and the | |
| answer is far worse: 1 catch in 56 real bugfixes. Precision is the half that was | |
| easy to measure, which is presumably why it was measured first. | |
| ### Retrieval is question-driven | |
| Similarity search cannot surface a rule whose relevance depends on the answer. Pinning | |
| mitigates this for known traps and does not solve it in general. | |
| Every rule is now reachable by its own probe (Β§3.19), which is a floor and not a | |
| ceiling: it proves each rule beats its stack for one question chosen to suit it. It | |
| says nothing about which rule wins when two are plausible. | |
| Compliance checking no longer depends on retrieval: the domain and semantic layers run | |
| every checkable rule for the stack, not only the ones the model was shown. Retrieval | |
| decides what the model *saw*; it has no bearing on which rules the answer is allowed to | |
| break, and a rule that was never retrieved is exactly the one most likely to be broken | |
| unawares. | |
| ### Corpus provenance | |
| <!--fig:corpus.sourced-->26<!--/fig--> of <!--fig:corpus.rules-->28<!--/fig--> rules cite upstream documentation. The rest rest on general knowledge and | |
| deserve more scepticism. Most of the rules were written by an assistant rather than | |
| harvested from production incidents, and rules drawn from real incidents are worth | |
| more. Four now are: `commit-inside-a-loop` and `sql-needs-query-parameters` came | |
| from an external ruleset disagreeing with this one (3.34), and | |
| `depends-must-not-name-unread-fields` from a fix commit in OpenSPP's history (3.35), and | |
| `sql-constraints-are-ignored-from-19` from an OpenSPP incident (3.62). Four of | |
| twenty-eight is not a change of method yet. | |
| This is the deepest unresolved problem in the design and no layer addresses it: the | |
| validator and the thing being validated are the same class of system. Provenance | |
| tracks sources, versions and checker identities. It cannot establish that a rule is | |
| *right*, only where it came from. | |
| ### Scope | |
| Odoo, and nothing else (Β§3.46). Odoo rules were checked against 18.0 documentation and | |
| its source; behaviour differs across major versions. | |
| One stack, <!--fig:corpus.rules-->28<!--/fig--> rules: <!--fig:corpus.origin.odoo-->20<!--/fig--> about the ORM and <!--fig:corpus.origin.python-->8<!--/fig--> about the Python language, which apply | |
| because an Odoo module is a Python module. Both halves carry field evidence from two | |
| ecosystems and a hand classification of every finding, and they are **not the same | |
| quality** β <!--fig:cls.openspp.precision-->84<!--/fig-->% on the two flagship ORM | |
| rules, <!--fig:cls.python.precision-->18<!--/fig-->% on the composed-in Python half. | |
| Reporting one blended number would describe neither. | |
| A `.py` or `.xml` file is reviewed. Anything else is refused by name rather than | |
| reviewed against rules that cannot match it. | |
| ### Known imprecision, not yet fixed | |
| `check-company-on-relations` was a regex over one declaration, and `check_company=True` | |
| is only meaningful on a model that has a `company_id` of its own. Of its 37 hits on | |
| OCA, 5 were in files declaring no `company_id` at all β where the advice is not merely | |
| unnecessary but wrong β and 11 were on wizards. A regex cannot ask "does this model | |
| have a company_id", so the rule moved to a `semantic_check`, as `ondelete-is-a-decision` | |
| did. On the 2026-09-15 run it fires 19 times: every one on a model that declares its own | |
| `company_id`, and none on a wizard. Both classes are gone. | |
| Four of those 19 were `related=` mirrors of a field on the parent and one a computed | |
| `Many2many` with no stored column. Both were single predicates on the field's own | |
| declaration and both shipped in Β§3.43, and a SQL view followed in Β§3.58; this rule has | |
| no live false positive on the three trees now. | |
| What remains known and unfixed is listed with the rest of the worklist, not here: | |
| one `sudo` rebind over registry models only, which needs every call after the rebind | |
| proved safe and is left classified rather than engineered (Β§3.58), and the `sudo` calls | |
| Β§3.64 and Β§3.65 leave standing on caller-supplied ids and helpers. The reset-on-change | |
| `depends-unread` shape (Β§3.57) is not on that list: it stays a finding by decision, because | |
| a value reset when its dependency changes and a leftover dependency look the same in one | |
| file, and the OpenSPP fixes the rule was harvested from were leftovers. Nor are reads inside a | |
| lambda: the depends and constrains checks leave them unread by measurement (Β§3.72), since what a | |
| filter tests mostly selects records rather than feeds the value, and telling the two apart needs | |
| facts from other files. Nor is a read through a compute's own output, which stays exempt by | |
| measurement too (Β§3.73): 16 of its 17 true findings on core re-check a smart default's own | |
| choice, and it made more false ones. | |
| ### `nexa check` on a large real file | |
| Pointed at `base_multi_image/models/image.py` β a 7,657-character OCA module with | |
| six constraint methods β the 7B answered "the code uses @api.constrains for | |
| validation, which is correct for this use case", cited no rule ids, and found | |
| nothing. The layers reported that honestly (`knowledge: nothing checked β the | |
| answer cites no rule ids`) rather than dressing it up. That run was also | |
| truncated at 6,000 characters without saying so, which is now fixed. | |
| That was one observation. 3.26 is the measurement, and it was worse than the | |
| observation suggested: over 25 reviews the command never returned PASS, and on | |
| the two Odoo files it reported none of the nine findings the same corpus | |
| produces on those files with no model involved. | |
| 3.28 is the repair. The checkers read the file now, so all nine are reported with | |
| their line numbers, and the excerpt is chosen by symbol so the method carrying | |
| three of them is reviewed rather than cut off. What remains true is the shape of | |
| the risk: the deterministic checkers carry the weight on real code, and the | |
| generative half still degrades as the file grows -- a median of two symbols named | |
| out of nineteen on a 7,929-character file, in the run of 2026-09-27 (3.75). The | |
| difference is that the tool no longer depends on that half to find anything. | |
| ### Untested platforms | |
| macOS has never run this, and Windows Terminal specifically has not been exercised | |
| (PowerShell and conhost have). Everything else in the environment matrix is covered: | |
| Linux through WSL, Python 3.11 and 3.14 on both, the colour opt-outs, and a stdout | |
| that cannot encode the banner. | |
| ### Escalation | |
| Measured on 2026-10-01: `nexa eval --runs 5 --pipeline` over the nine Odoo questions, | |
| 45 pipeline runs, recorded in `artifacts/eval-2026-09.json` (3.81): | |
| | | | 95% CI | | |
| |---|---|---| | |
| | PASS | 43/45 (96%) | 85β99% | | |
| | UNVERIFIED | 2/45 (4%) | 1β15% | | |
| | Escalation triggered | 9/45 (20%) | 11β34% | | |
| | Escalation rescued a bad draft | 7/9 (78%) | 45β94% | | |
| | Same verdict on every run | 8/9 questions | | | |
| Nine escalations make a wide interval: the 30B repairs somewhere between a little under | |
| half and nearly all of the drafts it is handed, and 45 runs cannot narrow that further. | |
| The denominator is a firm one, though. Every escalation in it is on a critical finding a | |
| larger model can act on, five onchanges and four stored computes, because an advisory | |
| finding escalates nothing since 3.81. Under the old rule the same 45 drafts would have | |
| escalated 19 times. | |
| **Before it, on 2026-09-27**, the same nine questions under the old rule (3.77): | |
| | | | 95% CI | | |
| |---|---|---| | |
| | PASS | 38/45 (84%) | 71β92% | | |
| | UNVERIFIED | 7/45 (16%) | 8β29% | | |
| | Escalation triggered | 18/45 (40%) | 27β55% | | |
| | Escalation rescued a bad draft | 11/18 (61%) | 39β80% | | |
| | Same verdict on every run | 8/9 questions | β | | |
| 10 of those 18 escalated on an advisory finding alone: the negative-quantity question five | |
| times, never rescued, and the positive-amount question five times, each counted a rescue | |
| when the 30B's answer happened not to trip the rule. Without them the rate read 6 of 8. | |
| **Before that, on twelve questions.** The first measurement, on 2026-09-14, asked twelve | |
| questions, six of them TypeScript and JavaScript, which have since left the corpus: | |
| | | | 95% CI | | |
| |---|---|---| | |
| | PASS | 50/60 (83%) | 72β91% | | |
| | UNVERIFIED | 10/60 (17%) | 9β28% | | |
| | Escalation triggered | 21/60 (35%) | 24β48% | | |
| | Escalation rescued a bad draft | 11/21 (52%) | 32β72% | | |
| | Same verdict on every run | 12/12 questions | β | | |
| The entry before that read "10 runs recorded, 2 escalated, 1 rescued β a rescue | |
| rate of 1/2 on a sample of 2, which means nothing yet". 21 escalations was enough to | |
| be evidence, and the interval was what that evidence looked like: the 30B repaired | |
| somewhere between a third and three quarters of the drafts it was handed, and 60 runs | |
| could not narrow that further. Quoting 52% as a figure would have been the same | |
| mistake in a new suit. | |
| **Re-run after the corpus grew to 43 rules**, with `ai-odoo` re-registered so the | |
| measurement was of the prompt that actually ships rather than the one Ollama still | |
| had. Nothing moved that was supposed to be stable: | |
| | | before | after | | |
| |---|---|---| | |
| | PASS / UNVERIFIED | 50 / 10 | 50 / 10 | | |
| | Same verdict on every run | 12/12 | 12/12 | | |
| | Per-question verdict | β | identical on all twelve | | |
| | Escalation triggered | 22/60 | 21/60 | | |
| | Escalation rescued | 12/22 | 11/21 | | |
| | Median seconds | 6.6 | 5.0 | | |
| One answer's worth of movement in the escalation columns, well inside intervals | |
| that span twenty-four points. The point of re-running was not to find a change: it | |
| was that "three new rules should not have changed `ask`" is a prediction, and this | |
| is the difference between having made it and having checked it. | |
| The stability line is the one worth reading twice. On 2026-10-01, as on 2026-09-27, | |
| eight of the nine questions reached the same verdict all five times, which is not a given | |
| at temperature 0.15 and is the property that makes a single `nexa ask` worth anything | |
| at all. The ninth is again storing a total in a monetary field. Four of its five drafts | |
| had a stored compute reading a field its `@api.depends` does not declare, and the 30B | |
| repaired two of the four. | |
| Escalation is meant to fire only on findings a larger model could plausibly repair | |
| (Β§3.16). An advisory finding is not one: on 2026-09-27 the negative-quantity question | |
| escalated on one five times in five, and the 30B wrote the same constraint as the 7B | |
| every time (3.77). Since Β§3.81 that question stays on the 7B, and it passed five times | |
| in five. | |
| --- | |
| ## 7. Layout | |
| ``` | |
| nexa/ | |
| βββ nexa.py CLI entry point, banner, help | |
| βββ nexa_cmds.py rules / check / doctor / build / models | |
| βββ nexa.cmd, nexa launchers (Windows, POSIX) | |
| βββ corpus/ | |
| β βββ rule.schema.json the schema rules are validated against | |
| β βββ build_corpus.py RULE SOURCE OF TRUTH + patterns + provenance | |
| β βββ corpus_meta.json generated; hash, build time, checked_against | |
| β βββ selftest_patterns.py negative test for both self-checks | |
| β βββ rules/odoo.json generated; the whole corpus, do not hand-edit | |
| βββ bin/ | |
| β βββ corpus.py validate, index, ask, crosscheck, coverage | |
| β βββ layers.py the nine layers, and the rule that combines them | |
| β βββ semantics.py AST checkers, comment stripping, fragment repair | |
| β βββ context.py what is true about the file being checked | |
| β βββ subject.py what `nexa check` decides before the model runs | |
| β βββ adversarial.py evasion / counterexample / contradiction probes | |
| β βββ fieldtest.py every checker against somebody else's source | |
| β βββ recall.py what the corpus misses, against real bugfixes | |
| β βββ surfaced.py how each fix came to light, checked against history | |
| β βββ figures.py every present-tense figure, recomputed from its artifact | |
| β βββ crosswalk.py this corpus against somebody else's ruleset | |
| β βββ eval.py the distribution: ask, the pipeline, retrieval, check | |
| β βββ endpoint.py where Ollama is, and what to say when it is not | |
| β βββ evidence.py per-run bundles: the record behind a verdict | |
| β βββ pipeline.py escalating pipeline, router-governed | |
| β βββ telemetry.py JSONL events + optional OTel bridge | |
| β βββ report.py aggregate analysis | |
| β βββ rag.py index your own code and docs | |
| β βββ local-ai.ps1 PowerShell helpers | |
| β βββ bench.ps1 raw throughput benchmark | |
| βββ modelfiles/ | |
| β βββ gen-odoo.Modelfile generated; do not hand-edit | |
| β βββ arch-advisor.Modelfile hand-written 30B persona | |
| βββ artifacts/ every committed measurement, which figures.py reads | |
| βββ subjects/ the fixed file set `nexa eval --check` reviews | |
| ``` | |
| One definition, not two. `layers.py` owns splitting an answer into code and prose, | |
| deciding what counts as a citation, and answering *is this rule broken here* β | |
| `corpus.py` re-exports those names so the CLI, the pipeline and the probes cannot | |
| drift apart on any of the three. | |
| Not in version control: `bin/index.sqlite` (rebuild with `nexa build`), | |
| `bin/telemetry.jsonl` (local measurements), `bin/evidence/` (per-run bundles), | |
| `__pycache__/`. | |