File size: 37,711 Bytes
d3cfa77
 
 
 
 
 
8d155aa
 
 
d3cfa77
 
 
 
8d155aa
 
d3cfa77
 
 
 
 
 
 
666f897
6faed22
d3cfa77
 
 
 
 
 
 
 
 
 
 
9012cfa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210b09e
 
 
 
 
 
 
d3cfa77
 
 
 
 
8d155aa
 
 
 
 
 
facddfd
8d155aa
 
921d283
 
d3cfa77
8d155aa
 
 
 
 
 
 
 
8b165a1
d3cfa77
8d155aa
 
 
d3cfa77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fdf138
 
 
 
d3cfa77
 
 
 
 
 
 
 
 
 
 
 
 
921d283
d3cfa77
 
 
 
 
 
921d283
 
 
 
 
d3cfa77
 
 
921d283
d3cfa77
 
 
 
 
 
 
521bc95
 
 
 
d3cfa77
 
 
 
 
 
921d283
 
 
 
 
d3cfa77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521bc95
 
 
 
d3cfa77
 
 
6faed22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d904a25
 
 
 
 
 
 
 
 
6faed22
 
 
 
 
 
 
223d4f4
6faed22
 
f6ab2f2
6faed22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ab2f2
 
 
d904a25
6faed22
 
 
 
 
 
 
 
210b09e
0e2212c
6faed22
 
223d4f4
 
 
 
 
5fdf138
 
6faed22
 
 
 
 
 
 
223d4f4
 
 
5fdf138
6faed22
 
 
 
 
5fdf138
 
 
 
 
6faed22
 
 
 
 
 
 
 
 
 
9012cfa
210b09e
 
0e2212c
 
6faed22
d904a25
6faed22
 
 
 
 
 
 
 
223d4f4
 
5fdf138
 
 
 
 
 
 
 
f6ab2f2
5fdf138
 
 
f6ab2f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5fdf138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6faed22
 
9012cfa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210b09e
9012cfa
 
 
 
 
 
 
 
 
 
 
210b09e
 
 
 
 
 
 
 
 
9012cfa
 
 
0e2212c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9012cfa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210b09e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6faed22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ab2f2
6faed22
 
 
 
 
 
 
f6ab2f2
 
 
 
 
 
 
 
d904a25
 
 
 
6faed22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6ab2f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d904a25
 
f6ab2f2
d904a25
f6ab2f2
 
 
 
 
 
 
 
 
 
 
d904a25
 
f6ab2f2
d904a25
 
 
 
 
 
 
 
f6ab2f2
 
d904a25
 
f6ab2f2
d904a25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6faed22
 
666f897
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6faed22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3cfa77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
#!/usr/bin/env python3
"""What is true about the place this code will actually live.

WHY THIS EXISTS
---------------
ARCHITECTURE records the context layer as absent by design, and names the reason
honestly: odoo.ondelete-is-a-decision cannot be decided from a snippet, because
whether a dangling reference matters depends on whether anything ever reads the
field back -- context a snippet does not contain.

That reasoning is correct about snippets and wrong about files. When the input is
a real path in a real tree, the context is sitting on disk: the module manifest
says which Odoo version this has to run on, the file says which model it extends,
ir.model.access.csv says whether the model can be deleted at all. None of it
needs a model to guess.

So this module reads the surroundings, and the context layer reports UNKNOWN --
never PASS -- whenever it was handed a snippet with no surroundings to read. An
absent context is a stated gap, not a clean bill of health.

Stdlib only.
"""
import ast
import csv
import io
import json
import os
import re

CONTEXT_VERSION = "1.0.0"

MANIFEST_NAMES = ("__manifest__.py", "__openerp__.py")
MAX_WALK_UP = 6

_VERSION_RE = re.compile(r"[\"']version[\"']\s*:\s*[\"']([0-9.]+)[\"']")

# An <record model="ir.rule"> and the two fields that decide whether it filters
# anything. Attribute order is not fixed -- one OpenSPP file writes ref= before
# name= on every row, which is 10 of that tree's 239 rules -- so model_id is
# matched without assuming which attribute comes first.
_IR_RULE_RE = re.compile(
    r"<record[^>]*\bmodel=[\"']ir\.rule[\"'][^>]*>(.*?)</record>", re.S)
_RULE_MODEL_RE = re.compile(
    r"<field\b(?=[^>]*\bname=[\"']model_id[\"'])[^>]*\bref=[\"']([\w.]+)[\"']")
_RULE_DOMAIN_RE = re.compile(
    r"<field\b[^>]*\bname=[\"']domain_force[\"'][^>]*>(.*?)</field>", re.S)

# A domain that matches every row filters nothing, so sudo cannot drop it. This
# is the whole of `[(1, '=', 1)]` in any spacing; anything else is treated as a
# real filter, because deciding that a domain is vacuous in general means
# evaluating it.
_VACUOUS_DOMAIN = re.compile(r"^\[\s*\(\s*1\s*,\s*[\"']=[\"']\s*,\s*1\s*\)\s*\]$")

# The left operand of every leaf in a domain: the fields the rule filters ON.
# A domain is a flat list of triples once the operators are ignored, and the
# first element of each is a field path. Read for one purpose only -- deciding
# whether a call under sudo names the field the rule it bypasses is about --
# and a field this fails to read simply is not offered as an exemption.
_RULE_FIELD_RE = re.compile(r"[\(\[]\s*[\"']([a-z_][a-z0-9_.]*)[\"']\s*,")
_NAME_RE = re.compile(r"[\"']name[\"']\s*:\s*[\"']([^\"']+)[\"']")
_MODEL_RE = re.compile(r"^\s*_(?:name|inherit)\s*=\s*[\"']([a-z0-9_.]+)[\"']", re.M)
_ODOO_IMPORT_RE = re.compile(r"^\s*from\s+odoo[\w.]*\s+import\s+(.+)$", re.M)


# What this corpus is able to review, and nothing else. One table, read by
# subject.route as well, so a file cannot be refused by the command and
# contextualised here anyway.
#
# .py is where every rule but one lives; .xml carries the noupdate rule, whose
# violation is a <record> in a data file. Anything else is refused by name
# rather than reviewed: the Odoo rules run over a .ts file find nothing, and
# nothing reads as a clean bill of health.
REVIEWABLE = (".py", ".xml")


def stack_for(path):
    """The rule stack a file belongs to. There is one, and this says whether
    the file is in it.

    Returns "odoo" for a file the corpus can review and None for one it cannot.
    Both an Odoo module and a plain Python script return "odoo": the corpus
    holds the ORM rules and the plain-Python rules together, so there is no
    longer a second stack to send a script to, and a script inside an Odoo
    repository -- scripts/, tools/, a migration helper -- is reviewed against
    the language rules, with the ORM rules finding nothing in it.
    """
    if os.path.splitext(path)[1].lower() in REVIEWABLE:
        return "odoo"
    return None


def _walk_up(start, names):
    """Nearest ancestor directory containing any of `names`, with the hit."""
    directory = os.path.dirname(os.path.abspath(start))
    for _ in range(MAX_WALK_UP):
        for name in names:
            candidate = os.path.join(directory, name)
            if os.path.isfile(candidate):
                return candidate
        parent = os.path.dirname(directory)
        if parent == directory:
            break
        directory = parent
    return None


_READ_LIMIT = 200000


def _read(path, limit=_READ_LIMIT):
    try:
        with io.open(path, encoding="utf-8", errors="ignore") as fh:
            return fh.read(limit)
    except OSError:
        return ""


def _odoo_series(version):
    """'18.0.1.0.3' -> '18.0'. Odoo module versions carry the series in front."""
    parts = version.split(".")
    return ".".join(parts[:2]) if len(parts) >= 2 else version


def from_file(path, stack=None):
    """Everything knowable about a file's environment without running anything.

    Every key is either a real finding or None. None means 'not determinable
    here', and the context layer reports it as UNKNOWN rather than assuming a
    default -- an assumed Odoo version is worse than a missing one, because it
    silently answers a version question nobody verified.

    `stack` overrides the extension guess, and callers that have one should pass
    it: the extension cannot tell an Odoo module from a plain Python script, and
    a context labelled odoo goes looking for a __manifest__.py that a script was
    never going to have.
    """
    ctx = {
        "path": os.path.abspath(path),
        "stack": stack or stack_for(path),
        "source": "file",
        "odoo_series": None,
        "module": None,
        "manifest": None,
        "models": [],
        "odoo_imports": [],
        "is_test": False,
        # How much of the file the answer was about. Filled in by subject.plan,
        # which is the only thing that knows; None means nobody chose an
        # excerpt, which is the case for a bare question.
        "reviewed": None,
    }
    body = _read(path)
    base = os.path.basename(path).lower()
    ctx["is_test"] = base.startswith("test_") or ".test." in base or ".spec." in base \
        or os.sep + "tests" + os.sep in ctx["path"].lower()

    if ctx["stack"] in ("odoo", "python"):
        # Both gather the same way. A .py file that turns out to be plain Python
        # has no manifest above it -- that is how it was routed to python in the
        # first place -- so the walk costs nothing and the alternative is two
        # branches that have to stay in step.
        manifest = _walk_up(path, MANIFEST_NAMES)
        if manifest:
            text = _read(manifest)
            ctx["manifest"] = manifest
            ctx["module"] = os.path.basename(os.path.dirname(manifest))
            found = _VERSION_RE.search(text)
            if found:
                ctx["odoo_series"] = _odoo_series(found.group(1))
            elif _NAME_RE.search(text):
                ctx["odoo_series"] = None      # a manifest with no version pin
        ctx["models"] = sorted(set(_MODEL_RE.findall(body)))
        imports = []
        for line in _ODOO_IMPORT_RE.findall(body):
            imports.extend(part.strip() for part in line.split(",") if part.strip())
        ctx["odoo_imports"] = sorted(set(imports))
    return ctx


def empty(stack):
    """The context for a bare question: nothing known, and said so.

    Returned for `nexa ask`, where there is no file and therefore no
    surroundings. The layer above turns this into UNKNOWN.
    """
    return {
        "path": None,
        "stack": stack,
        "source": "none",
        "odoo_series": None,
        "module": None,
        "manifest": None,
        "models": [],
        "odoo_imports": [],
        "is_test": False,
        # How much of the file the answer was about. Filled in by subject.plan,
        # which is the only thing that knows; None means nobody chose an
        # excerpt, which is the case for a bare question.
        "reviewed": None,
    }


# ------------------------------------------------------------ tree triggers
#
# Some rules are conditional on a fact no single file contains.
#
#   ondelete-is-a-decision    the harm is a dangling read after the PARENT is
#                             deleted. If nothing ever deletes the parent -- if
#                             it is master data -- there is no dangling read to
#                             have.
#   check-company-on-relations  the 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 that
# describes a CONSTRUCT into one that describes a DEFECT -- for the subset where
# the answer is available, and only there. The three-valued result is the whole
# point: PRESENT promotes the finding, ABSENT drops it, and UNKNOWN leaves the
# rule exactly where it was rather than inventing a default. This module already
# reports UNKNOWN over an assumed Odoo version for the same reason.

PRESENT = "present"
ABSENT = "absent"
UNKNOWN = "unknown"

# ir.model.access.csv is where Odoo records whether a model can be deleted AT
# ALL. A model with perm_unlink 0 in every row cannot be deleted through the UI
# by anybody, which is the half of the question reading Python can never answer.
_PERM_UNLINK_TRUE = ("1", "true", "True", "TRUE")

# For the module-sized walk only. fieldtest owns the list for whole-tree walks,
# because it owns the walk.
_SKIP_DIRS = frozenset([".git", ".hg", "node_modules", "static", "__pycache__",
                        ".tox", ".venv", "venv", "dist", "build", "migrations"])
_TRIGGER_EXTENSIONS = (".py", ".csv", ".xml")
_MODULE_FILE_CAP = 400

# The modules a checked module depends on are read for their ORM declarations
# alone (module_facts). Test directories are left out of that read, as
# fieldtest's trigger walk leaves them out: a model a test declares is not
# installed with the module. Past the cap the read costs seconds, about two
# for a thousand files of Odoo core on the machine it was measured on.
_TEST_DIRS = frozenset(["tests", "test", "__tests__", "spec"])
_CLOSURE_FILE_CAP = 1000
_MODULE_NAME_RE = re.compile(r"^[A-Za-z0-9_]+$")


def no_tree():
    """The facts for a file with no tree around it: nothing walked, nothing
    claimed. Every trigger reads UNKNOWN off this, which is what `nexa check`
    on a single path gets and is the honest answer there."""
    return {"walked": False, "complete": False, "files": 0,
            "models_declared": set(), "models_deleted": set(),
            "multi_company": None, "odoo_series": None}


def tree_facts(paths, complete=True):
    """Walk a tree once and record what the triggers need to know.

    `paths` is an iterable of file paths -- the caller owns the walk, because the
    module that already knows how to skip a node_modules is the one that should
    keep doing it. Python files, ir.model.access.csv and XML data files are each
    read for what only they can say.

    `complete` is whether those paths were the WHOLE codebase. It decides what
    the facts may be used for, and the two uses have different bars:

      promoting   positive evidence stays positive however little was read. A
                  module that deletes spp.vocabulary deletes it.
      suppressing "nothing deletes this" is only true of everything that was
                  read, so a partial walk must not say it. A model declared in
                  one module and deleted in another would otherwise vanish from
                  the report on the strength of not looking.

    So `nexa check` walks one module and gets promotions only; `fieldtest` walks
    the roots it was given and gets both.

    The ORM facts keep what they were made from, `orm_inputs`, so module_facts
    can add the modules a checked module depends on without reading its own
    files a second time.

    Returns a dict whose `walked` is True. That flag is the difference between
    "this tree does not delete res.partner" and "nobody looked", and every
    trigger below refuses to answer without it.
    """
    import semantics      # local: context is imported by modules semantics is not

    declared, deleted = set(), set()
    unlink_tokens = set()
    ruled = {}
    rule_sets = {}
    multi_company = False
    seen = 0
    # Every series the manifests in this walk name. Kept as a set because
    # disagreement is not an average: a tree holding a 16 module beside a 19
    # module has no single answer, and a checker that needs the series must
    # hear that it cannot have one rather than be handed whichever came first.
    series_seen = set()
    manifests = {}
    declarations = []

    for path in paths:
        lower = path.lower()
        body = _read(path)
        if not body:
            continue
        seen += 1
        if os.path.basename(lower) in MANIFEST_NAMES:
            found = _VERSION_RE.search(body)
            series_seen.add(_odoo_series(found.group(1)) if found else None)
            manifests[os.path.dirname(os.path.abspath(path))] = _manifest_depends(body)
        if lower.endswith(".py"):
            declared |= semantics.models_declared(body)
            deleted |= semantics.models_unlinked(body)
            if not multi_company and semantics.declares_multi_company(body):
                multi_company = True
            # Read whole for this: _read stops at 200,000 characters, and
            # account_move.py, account_tax.py and mail_thread.py are longer.
            # A file cut short does not parse, and would declare nothing.
            whole = _read(path, None) if len(body) >= _READ_LIMIT else body
            declarations.append((path, semantics.orm_declarations(whole)))
        elif lower.endswith(".csv"):
            if os.path.basename(lower).startswith("ir.model.access"):
                unlink_tokens |= _models_granted_unlink(body)
        elif lower.endswith(".xml"):
            # Multi-company record rules live in XML and nowhere else. The same
            # markers work here: a domain reading company_ids is the rule that
            # only exists when several companies do.
            if not multi_company and any(m in body for m in
                                         semantics.MULTI_COMPANY_MARKERS):
                multi_company = True
            if "ir.rule" in body:
                for model, fields in _models_with_a_filtering_rule(body).items():
                    ruled.setdefault(model, set()).update(fields)
                for model, rules in _rules_one_by_one(body).items():
                    rule_sets.setdefault(model, []).extend(rules)


    # Access tokens are matched FORWARDS, from each declared model to the token
    # it would have. Parsing `model_sale_order_line` back into a dotted name is
    # ambiguous -- sale.order.line and sale.order_line spell it identically --
    # and going this way there is nothing to guess.
    for model in declared:
        if "model_" + model.replace(".", "_") in unlink_tokens:
            deleted.add(model)

    # One series, agreed by every manifest that pinned one, or nothing.
    pinned = {v for v in series_seen if v}
    facts = {"walked": True, "complete": complete, "files": seen,
             "models_declared": declared, "models_deleted": deleted,
             "models_record_ruled": ruled,
             "models_rule_fields": {m: tuple(sorted(f)) for m, f in ruled.items()},
             "models_rule_sets": rule_sets,
             "multi_company": multi_company,
             "odoo_series": pinned.pop() if len(pinned) == 1 else None}
    facts.update(_orm_facts(declarations, manifests))
    facts["orm_inputs"] = (declarations, manifests)
    return facts


def _with_declarations(facts, paths):
    """The facts with what more files declare added to their ORM facts, and
    nothing else: the modules a checked module depends on (module_facts).

    Python files only, read for their models' fields and methods and their
    manifests' depends. What they declare can only add to what the depends
    check finds declared, so the walk is no more complete than it was, and
    every other fact stays that of the walk.
    """
    import semantics      # local, as in tree_facts

    declarations, manifests = facts["orm_inputs"]
    declarations, manifests = list(declarations), dict(manifests)
    for path in paths:
        lower = path.lower()
        body = _read(path)
        if not body or not lower.endswith(".py"):
            continue
        if os.path.basename(lower) in MANIFEST_NAMES:
            manifests[os.path.dirname(os.path.abspath(path))] = _manifest_depends(body)
        whole = _read(path, None) if len(body) >= _READ_LIMIT else body
        declarations.append((path, semantics.orm_declarations(whole)))
    wider = dict(facts)
    wider.pop("with_dependencies", None)
    wider.update(_orm_facts(declarations, manifests))
    wider["orm_inputs"] = (declarations, manifests)
    return wider


def _manifest_depends(body):
    """The modules a manifest depends on, or () when it cannot be read."""
    try:
        manifest = ast.literal_eval(body.strip())
    except (ValueError, SyntaxError, MemoryError, RecursionError):
        return ()
    depends = manifest.get("depends") if isinstance(manifest, dict) else None
    return tuple(d for d in depends or () if isinstance(d, str))


def _orm_facts(declarations, manifests):
    """What the walk declares about its models, each part tagged with its module.

    The depends check asks three things no single file answers: whether a
    compute's field is stored, which @api.depends Odoo merges into a method from
    its other definitions, and what a declared computed field depends on in
    turn. Each answer is only good where the module that gives it is installed,
    so every declaration keeps the module it ships in, and a module is the
    directory holding its manifest.

      modules       {module: the modules its manifest depends on}
      module_dirs   {directory: module}
      orm_fields    {model: {field: [(module, declaration)]}}
      orm_methods   {model: {method: [(module, @api.depends or None)]}}
      orm_parents   {model: [(module, a model it inherits)]}
      orm_named     the models some class in the walk names with _name
    """
    module_dirs = {directory: os.path.basename(directory) for directory in manifests}
    modules = {os.path.basename(directory): depends
               for directory, depends in manifests.items()}
    fields, methods, parents, named = {}, {}, {}, set()
    for path, records in declarations:
        module = _module_at(path, module_dirs)
        for record in records:
            model = record["model"]
            if record["named"]:
                named.add(model)
            for field, declaration in record["fields"].items():
                fields.setdefault(model, {}).setdefault(field, []).append(
                    (module, declaration))
            for method, depends in record["methods"].items():
                methods.setdefault(model, {}).setdefault(method, []).append(
                    (module, depends))
            for parent in record["parents"]:
                parents.setdefault(model, []).append((module, parent))
    return {"modules": modules, "module_dirs": module_dirs, "orm_fields": fields,
            "orm_methods": methods, "orm_parents": parents, "orm_named": named}


def _module_at(path, module_dirs):
    """The module a file ships in: the nearest directory above it holding a
    manifest the walk read. None for a file outside every module."""
    directory = os.path.dirname(os.path.abspath(path))
    while True:
        if directory in module_dirs:
            return module_dirs[directory]
        parent = os.path.dirname(directory)
        if parent == directory:
            return None
        directory = parent


def for_file(facts, path):
    """The tree facts as one file sees them: the same facts, and its module.

    The ORM facts are answered per module, and which module is asking is the
    one thing about them that changes from file to file. A shallow copy, so
    the walk's facts are shared and only the key is the file's own.
    """
    if not facts or not facts.get("walked"):
        return facts
    return dict(facts, module=_module_at(path, facts.get("module_dirs") or {}))


def module_closure(facts, module):
    """Every module installed whenever `module` is: itself, what its manifest
    depends on, transitively, and base, which every module depends on.

    None when the module is unknown, which is not the same as having none.
    """
    if not facts or not module:
        return None
    modules = facts.get("modules") or {}
    seen = {module, "base"}
    stack = [module]
    while stack:
        for depend in modules.get(stack.pop(), ()):
            if depend not in seen:
                seen.add(depend)
                stack.append(depend)
    return seen


def _models_with_a_filtering_rule(body):
    """Models this XML gives an ir.rule that actually filters something.

    A rule whose domain_force is `[(1, '=', 1)]` is a grant, not a filter: it
    exists to widen access for a group and matches every row. sudo() drops it
    and nothing changes, so a model whose only rules are those is no different
    from a model with none.

    Measured on OpenSPP, that distinction is most of this trigger's value: of
    the 30 `.sudo().search(` findings sitting on a model with any rule at all,
    10 were on spp.vocabulary.code, whose single rule is exactly that.

    The ref is turned into a dotted name forwards -- `model_spp_area` becomes
    spp.area -- which is ambiguous for a model whose own name contains an
    underscore. That is the same ambiguity _models_granted_unlink avoids by
    matching forwards from declared models, and it is not avoidable here
    because the rule names the model and nothing else does.

    A rule this fails to read therefore reads as "no rule", and for a model the
    tree declares that SUPPRESSES a finding -- the unsafe direction, and the one
    thing in this trigger worth being careful about. Two things hold it down:
    the parse is deliberately loose about attribute order, because one OpenSPP
    file writes `ref=` before `name=` on all ten of its rules and a stricter
    pattern silently lost them; and a model whose rules are all unreadable is a
    model nobody can show is protected, which is a finding worth making anyway.
    """
    found = {}
    for block in _IR_RULE_RE.findall(body):
        ref = _RULE_MODEL_RE.search(block)
        if not ref:
            continue
        token = ref.group(1).rsplit(".", 1)[-1]
        if not token.startswith("model_"):
            continue
        domain = _RULE_DOMAIN_RE.search(block)
        text = " ".join(domain.group(1).split()) if domain else ""
        if text and _VACUOUS_DOMAIN.match(text):
            continue
        model = token[len("model_"):].replace("_", ".")
        fields = found.setdefault(model, set())
        for path in _RULE_FIELD_RE.findall(text):
            # Both the path and the relation it starts from. A rule reading
            # `case_id.case_worker_id` is satisfied by a create that names
            # `case_id`, because the case it points at is one the caller
            # can already see and the rule follows the link to find out.
            fields.add(path)
            fields.add(path.split(".", 1)[0])
    return found


def _rules_one_by_one(body):
    """{model: [(is_global, fields)]}, one entry per filtering rule.

    _models_with_a_filtering_rule merges every rule's fields per model, which
    answers "is this model ruled" and cannot answer "did this call re-apply the
    rules". Odoo ANDs global rules -- the ones with no groups -- for everybody,
    and applies group rules per audience. product.template carries both: a
    global company rule, and a website rule for public and portal users only.
    A sudo() search naming website_published re-applied the second and not the
    first, and the merged set read that as re-scoped.
    """
    found = {}
    for block in _IR_RULE_RE.findall(body):
        ref = _RULE_MODEL_RE.search(block)
        if not ref:
            continue
        token = ref.group(1).rsplit(".", 1)[-1]
        if not token.startswith("model_"):
            continue
        domain = _RULE_DOMAIN_RE.search(block)
        text = " ".join(domain.group(1).split()) if domain else ""
        if text and _VACUOUS_DOMAIN.match(text):
            continue
        fields = set()
        for path in _RULE_FIELD_RE.findall(text):
            fields.add(path)
            fields.add(path.split(".", 1)[0])
        is_global = not re.search(r"name\s*=\s*[\"']groups[\"']", block)
        found.setdefault(token[len("model_"):].replace("_", "."), []).append(
            (is_global, tuple(sorted(fields))))
    return found


def model_rule_sets(facts, model):
    """This model's filtering rules one by one, as (is_global, fields).

    Empty when the tree declares no readable rule for it, which leaves callers
    on their fallback.
    """
    if not facts or not facts.get("walked") or not model:
        return ()
    return tuple(facts.get("models_rule_sets", {}).get(model, ()))


def model_is_record_ruled(facts, model):
    """Would sudo() on this model actually drop a filter?

    PRESENT if the tree declares an ir.rule for it that filters. ABSENT if a
    whole tree was walked, the tree DECLARES the model, and no filtering rule
    came with it. UNKNOWN otherwise.

    The rule this backs says sudo "drops ir.rule filtering". Where there is no
    filtering to drop, the finding describes something that cannot happen, and
    on OpenSPP that was 83 of 106 findings.

    The model must be declared here for ABSENT to mean anything, and getting
    that wrong is measurable rather than theoretical. A walk of an addons tree
    sees the addons' rules and not Odoo's own: product.template carries core's
    company rule, which lives in the `product` module and not in the tree being
    read. Answering ABSENT from that silence dropped three true positives in
    spp_api_v2_products on the first run of this trigger -- a sudo lookup of
    every company's products, which is exactly the leak the rule is about.

    So silence about a model nobody declared is UNKNOWN, and the finding
    stands. The tree can say "we declare this and give it no rule". It cannot
    say anything about a model it never saw.
    """
    if not facts or not facts.get("walked") or not model:
        return UNKNOWN
    if model in facts.get("models_record_ruled", ()):
        return PRESENT
    if not facts.get("complete"):
        return UNKNOWN
    return ABSENT if model in facts.get("models_declared", ()) else UNKNOWN


def model_rule_fields(facts, model):
    """The fields this model's own filtering rules are written about.

    `[('case_worker_id', '=', user.id)]` yields `case_worker_id`, and
    `[('case_id.case_worker_id', '=', user.id)]` yields both the path and
    `case_id`, the relation it is reached through.

    This exists so a call under sudo can be compared against the rule it
    would actually bypass, instead of against the word `company_id`. A
    per-user rule is as real as a multi-company one -- OpenSPP's case models
    are governed entirely by `case_worker_id` and `case_id.case_worker_id`,
    and not one of them has a company rule at all -- so a checker that knows
    only the multi-company spelling cannot see when the advice was followed.

    Empty for a model nobody declared a readable rule for, which offers no
    exemption and leaves the finding standing.
    """
    if not facts or not facts.get("walked") or not model:
        return ()
    return tuple(facts.get("models_rule_fields", {}).get(model, ()))


def _models_granted_unlink(body):
    """The `model_id:id` tokens an ir.model.access.csv grants perm_unlink on."""
    rows = list(csv.reader(io.StringIO(body)))
    if not rows:
        return set()
    header = [cell.strip() for cell in rows[0]]
    try:
        model_at = header.index("model_id:id")
        unlink_at = header.index("perm_unlink")
    except ValueError:
        return set()
    granted = set()
    for row in rows[1:]:
        if len(row) <= max(model_at, unlink_at):
            continue
        if row[unlink_at].strip() not in _PERM_UNLINK_TRUE:
            continue
        # Rows carry either `model_x_y` or `module.model_x_y`.
        token = row[model_at].strip().split(".")[-1]
        if token:
            granted.add(token)
    return granted


def module_facts(path, stack, addons=()):
    """Tree facts for the Odoo module a single file sits in, or nothing.

    `nexa check` is handed one path, and the tree that would answer its triggers
    is on disk around it. Walking the whole repository for one file would cost
    more than the review; walking the MODULE is bounded, and a module is the unit
    Odoo itself ships and installs.

    The modules its manifest depends on, transitively, can be read as well,
    beside it or in `addons`, for their ORM declarations alone
    (_dependency_sources). Each is installed whenever this one is, and the
    depends check needs them: Odoo merges a compute's @api.depends from their
    definitions and triggers through their fields (ARCHITECTURE 3.84). They
    are read only when the depends check asks (with_dependencies), which it
    does for a read the module's own declarations leave uncovered: most files
    never have one, and reading them cost seconds (ARCHITECTURE 3.86).

    Marked incomplete, so nothing it did not read can drop a finding. What it
    read can promote one, and a declaration it read can answer the depends
    check. See tree_facts for why those need different bars.
    """
    if stack != "odoo" or not path:
        return no_tree()
    manifest = _walk_up(path, MANIFEST_NAMES)
    if not manifest:
        return no_tree()
    root = os.path.dirname(manifest)
    found = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
        for name in sorted(filenames):
            if name.endswith(_TRIGGER_EXTENSIONS):
                found.append(os.path.join(dirpath, name))
        if len(found) > _MODULE_FILE_CAP:
            # A module this large is not the bounded walk this was meant to be.
            # Better to say nothing than to make `check` feel like a build.
            return no_tree()
    facts = for_file(tree_facts(found, complete=False), path)

    def wider():
        sources = _dependency_sources(manifest, addons)
        return _with_declarations(facts, sources) if sources else None

    facts["with_dependencies"] = _OnFirstUse(wider)
    return facts


class _OnFirstUse(object):
    """A value made the first time it is asked for, and kept for every later ask.

    for_file copies the facts shallowly, so every file of a module shares one,
    and the modules it depends on are read at most once however many ask.
    """

    def __init__(self, make):
        self._make, self._made, self._value = make, False, None

    def __call__(self):
        if not self._made:
            self._value, self._made = self._make(), True
        return self._value


def with_dependencies(facts):
    """The facts with the modules the checked module depends on read as well.

    None when there is nothing more to read: a fieldtest walk read the whole
    tree already, and a module whose dependencies are not to be found, or
    come to more than the cap, has nothing to add.
    """
    more = (facts or {}).get("with_dependencies")
    return more() if more else None


def _dependency_sources(manifest, addons=()):
    """The Python files of every module this manifest's module depends on,
    transitively, that can be found: manifests included, tests left out.

    A dependency is looked for beside the module first, in the same directory,
    where an addons path keeps its modules, then in each directory of `addons`
    in turn, the first one holding it winning, as Odoo's --addons-path does.
    One installed anywhere else is not looked for, so for a module of Odoo
    core `base`, which core keeps apart from the rest, is read only when it is
    named. Breadth first and in name order, so a walk reads the same files
    every run. Nothing at all when they come to more than _CLOSURE_FILE_CAP:
    `check` then has the facts of the module alone, which can leave a finding
    standing and never makes one.
    """
    root = os.path.dirname(os.path.abspath(manifest))
    places = [os.path.dirname(root)] + [os.path.abspath(a) for a in addons]
    seen = {os.path.basename(root)}
    queue = sorted(_manifest_depends(_read(manifest)))
    found = []
    while queue:
        module = queue.pop(0)
        if module in seen or not _MODULE_NAME_RE.match(module):
            continue
        seen.add(module)
        declared = next((m for m in (_manifest_in(os.path.join(place, module))
                                     for place in places) if m), None)
        if declared is None:
            continue
        directory = os.path.dirname(declared)
        queue.extend(sorted(_manifest_depends(_read(declared))))
        for dirpath, dirnames, filenames in os.walk(directory):
            dirnames[:] = sorted(d for d in dirnames
                                 if d not in _SKIP_DIRS and d.lower() not in _TEST_DIRS)
            found.extend(os.path.join(dirpath, filename) for filename in sorted(filenames)
                         if filename.endswith(".py"))
        if len(found) > _CLOSURE_FILE_CAP:
            return []
    return found


def _manifest_in(directory):
    """The manifest a module directory holds, or None when it holds none."""
    for name in MANIFEST_NAMES:
        candidate = os.path.join(directory, name)
        if os.path.isfile(candidate):
            return candidate
    return None


def is_demo_data(path):
    """Is this file listed under its module manifest's `demo` or `demo_xml`?

    Odoo loads demo files with noupdate forced on (odoo/modules/loading.py:
    "noupdate is False, unless it is demo data"), so a record in one is never
    rewritten by an upgrade, whatever the file itself says. A file whose
    manifest cannot be found or read is not demo data as far as this can tell.
    """
    manifest = _walk_up(path, MANIFEST_NAMES)
    if not manifest:
        return False
    try:
        declared = ast.literal_eval(_read(manifest))
    except (ValueError, SyntaxError, TypeError):
        return False
    if not isinstance(declared, dict):
        return False
    root = os.path.dirname(manifest)
    target = os.path.normcase(os.path.abspath(path))
    for key in ("demo", "demo_xml"):
        for rel in declared.get(key) or ():
            if isinstance(rel, str) and os.path.normcase(
                    os.path.abspath(os.path.join(root, rel))) == target:
                return True
    return False


def parent_is_deleted(facts, model):
    """Does anything actually delete `model`?

    PRESENT  the tree deletes it: an unlink call, an unlink override, or an
             access rule granting perm_unlink.
    ABSENT   the tree DEFINES the model and never deletes it anywhere. Master
             data, as far as this codebase is concerned.
    UNKNOWN  no tree was walked, or the model is not one this tree defines.

    The ABSENT bar is deliberately the strict one, and has two conditions. The
    model must be one this tree DEFINES: a model reached by `_inherit` belongs to
    core Odoo or to a module this walk never saw, so a relation to res.partner
    reads UNKNOWN however much of the tree was read. And the walk must have been
    complete, because a module-sized walk cannot see the other module that does
    the deleting.
    """
    if not facts or not facts.get("walked") or not model:
        return UNKNOWN
    if model in facts["models_deleted"]:
        return PRESENT
    if model in facts["models_declared"] and facts.get("complete"):
        return ABSENT
    return UNKNOWN


def multi_company_in_use(facts):
    """Does this codebase run more than one company?

    PRESENT if the tree shows the machinery -- a user in several companies, code
    reading the active set, an existing check_company, a company record rule.
    ABSENT if a tree was walked and shows none of it. UNKNOWN if none was.
    """
    if not facts or not facts.get("walked"):
        return UNKNOWN
    if facts["multi_company"]:
        return PRESENT
    return ABSENT if facts.get("complete") else UNKNOWN


def describe(ctx):
    """One line per known fact, for the prompt and for the evidence bundle."""
    lines = []
    if ctx.get("module"):
        lines.append("Odoo module: {0}".format(ctx["module"]))
    if ctx.get("odoo_series"):
        lines.append("Odoo series: {0}".format(ctx["odoo_series"]))
    if ctx.get("models"):
        lines.append("Models in this file: {0}".format(", ".join(ctx["models"])))
    if ctx.get("odoo_imports"):
        lines.append("Already imported from odoo: {0}".format(", ".join(ctx["odoo_imports"])))
    if ctx.get("is_test"):
        lines.append("This file is a test, so throwing on bad input is acceptable here.")
    return lines


if __name__ == "__main__":
    import sys
    if len(sys.argv) > 1:
        print(json.dumps(from_file(sys.argv[1]), indent=2))
    else:
        print(__doc__)