codeanalyzer-python 1.4.0__tar.gz → 1.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/PKG-INFO +42 -11
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/README.md +41 -10
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/core.py +2 -2
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/builder.py +3 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/identity.py +3 -3
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/matching.py +36 -7
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/pipeline.py +52 -4
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/rules.py +12 -1
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/rules.yml +34 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/bolt.py +90 -57
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/cypher.py +19 -5
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/project.py +105 -44
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/rows.py +47 -1
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/schema.py +11 -25
- codeanalyzer_python-1.5.0/codeanalyzer/schema/ids.py +91 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/l1_body.py +2 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/py_schema.py +8 -2
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/pyproject.toml +1 -1
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/schema.neo4j.json +14 -20
- codeanalyzer_python-1.4.0/codeanalyzer/schema/ids.py +0 -44
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/.gitignore +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/LICENSE +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/NOTICE +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/__main__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/config_keys.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/config_use.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/config_use_rules.yml +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/dependencies.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/discovery.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/parsers.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/access_paths.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/alias.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/cfg.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/defuse.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/dominance.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/pdg.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/LICENSE +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/README.md +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/SSA/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/SSA/const.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/cfg/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/cfg/builder.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/cfg/model.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/core/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/core/func_call_visitor.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/core/vars_visitor.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel_oracle.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scc.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/sdg.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/slicing.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/summaries.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/syntactic.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/detect.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/jedi/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/jedi/jedi.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/emit.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/options/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/options/options.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/provenance.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/py.typed +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/assign_ids.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/call_graph_ids.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/l2_callees.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/semantic_analysis/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/semantic_analysis/call_graph.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/semantic_analysis/defuse_linker.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/syntactic_analysis/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/syntactic_analysis/exceptions.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/syntactic_analysis/import_resolver.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/syntactic_analysis/symbol_table_builder.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/utils/__init__.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/utils/logging.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/utils/progress_bar.py +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/docs/handoff/README.md +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/docs/handoff/schema.neo4j.json +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/schema-uml.drawio +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/flask/README.md +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/flask/examples/celery/README.md +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/flask/requirements-skip/README.md +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/flask/src/flask/sansio/README.md +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/requests/LICENSE +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/requests/NOTICE +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/requests/README.md +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/requests/docs/_themes/LICENSE +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/requests/ext/LICENSE +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/LICENSE +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/README.md +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/properties/README.md +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/xarray/datatree_/LICENSE +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/xarray/datatree_/README.md +0 -0
- {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/xarray/datatree_/docs/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: codeanalyzer-python
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Static analysis for Python — canonical schema v2 (symbol table, call graph, and native CFG/PDG/SDG dataflow) as analysis.json or a Neo4j property graph.
|
|
5
5
|
Author-email: Rahul Krishna <i.m.ralk@gmail.com>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -459,7 +459,7 @@ levels are cumulative and additive — `analysis.json(-a 1) ⊆ … ⊆ analysis
|
|
|
459
459
|
| **1** | `-a 1` (default) | Symbol table, Jedi call graph, and `call` nodes in each callable's `body` | `body` calls (`callee: null`) |
|
|
460
460
|
| **2** | `-a 2` | Defuse-linker call-graph enrichment; each call's `callee` backfilled to a `can://` id | `call_graph`, `body` callees |
|
|
461
461
|
| **3** | `-a 3` | Native **intraprocedural** CFG/CDG/DDG (syntactic, name-equality, `prov: ["ssa"]`) | `cfg`, `cdg`, `ddg`, `@entry`/`@exit` on each callable |
|
|
462
|
-
| **4** | `-a 4` | **Interprocedural** SDG: synthetic param vertices, alias-aware DDG (`prov: ["points-to"]`) | `param_in`, `param_out`, `summary`, semantic `ddg` |
|
|
462
|
+
| **4** | `-a 4` | **Interprocedural** SDG: synthetic param vertices, alias-aware DDG (`prov: ["points-to"]`), port-wiring DDG between statements and param vertices (`prov: ["reaching-defs"]`) | `param_in`, `param_out`, `summary`, semantic `ddg` |
|
|
463
463
|
|
|
464
464
|
`-a 1`/`-a 2` timings and output are unaffected by the heavier levels — nothing at level 3+ runs
|
|
465
465
|
unless requested. Flag gating: `--graphs sdg` requires `-a 4`; `--graphs cfg,dfg,pdg` and
|
|
@@ -481,7 +481,11 @@ symbol-table signature by construction
|
|
|
481
481
|
- **Points-to oracle (level 4):** the **Scalpel** may-alias oracle — `ScalpelAliasOracle`
|
|
482
482
|
(`codeanalyzer/dataflow/scalpel_oracle.py`) — consumes Scalpel's SSA copy/const facts to answer
|
|
483
483
|
`may_alias(path_a, path_b)`, adding the alias-aware DDG edges (`prov: ["points-to"]`) and the
|
|
484
|
-
interprocedural summaries.
|
|
484
|
+
interprocedural summaries. Level 4 also wires the statement-level DDG to the param
|
|
485
|
+
vertices (def → `actual_in`, `actual_out` → call site, `formal_in` → use, def → `formal_out`)
|
|
486
|
+
with `prov: ["reaching-defs"]`; without those the SDG would be two disconnected graphs. So
|
|
487
|
+
`prov` takes three values: `ssa` (syntactic, L3), `reaching-defs` (port wiring, L4) and
|
|
488
|
+
`points-to` (alias-derived, L4). Scalpel is **vendored** — a `typed_ast`-free slice built into the
|
|
485
489
|
package under `codeanalyzer/dataflow/scalpel/` — so it is the **default** level-4 oracle with no
|
|
486
490
|
external dependency to install; the analyzer falls back to the built-in `TypeBasedAliasOracle`
|
|
487
491
|
(Jedi-inferred types; unknown types conservatively alias) only when Scalpel can't resolve a
|
|
@@ -513,11 +517,11 @@ just populate more of the same tree:
|
|
|
513
517
|
"k_limit": 3, // access-path depth bound (--graph-field-depth); L3+ only
|
|
514
518
|
"analyzer": { "name": "codeanalyzer-python", "version": "1.0.0" },
|
|
515
519
|
"application": {
|
|
516
|
-
"id": "can
|
|
520
|
+
"id": "can://<app>",
|
|
517
521
|
"kind": "application",
|
|
518
522
|
"symbol_table": { // relative POSIX path → module
|
|
519
523
|
"pkg/mod.py": {
|
|
520
|
-
"id": "can
|
|
524
|
+
"id": "can://<app>/python/pkg/mod.py",
|
|
521
525
|
"kind": "module",
|
|
522
526
|
"source": "…full file text, stored once per module…",
|
|
523
527
|
"types": { "<Class>": { "id": "…", "kind": "class", "callables": { /* methods */ } } },
|
|
@@ -527,8 +531,8 @@ just populate more of the same tree:
|
|
|
527
531
|
"call_graph": [ { "src": "can://…/main(a)", "dst": "can://…/helper(x)",
|
|
528
532
|
"weight": 1, "prov": ["defuse", "jedi"] } ],
|
|
529
533
|
"external_symbols": { // imported/builtin call targets, keyed by id
|
|
530
|
-
"can
|
|
531
|
-
{ "id": "can
|
|
534
|
+
"can://<app>/@external/os/getcwd":
|
|
535
|
+
{ "id": "can://<app>/@external/os/getcwd", "kind": "external",
|
|
532
536
|
"name": "getcwd", "module": "os" }
|
|
533
537
|
},
|
|
534
538
|
"param_in": [ { "src": "can://…/main(a)@6:4/actual_in:0", "dst": "can://…/helper(x)@formal_in:0" } ],
|
|
@@ -546,7 +550,8 @@ A **callable** (function or method) carries its own CPG, keyed by node id:
|
|
|
546
550
|
"body": { // node id → node
|
|
547
551
|
"@entry": { "kind": "entry" },
|
|
548
552
|
"6:4": { "kind": "statement", "span": { … } },
|
|
549
|
-
"6:8": { "kind": "call", "span": { … },
|
|
553
|
+
"6:8": { "id": "can://…/main()@6:8", "kind": "call", "span": { … },
|
|
554
|
+
"callee": "can://…/helper(x)" }, // callee null until L2
|
|
550
555
|
"@formal_in:0": { "kind": "formal_in", "of": "a" }, // L4 param vertices
|
|
551
556
|
"6:4/actual_in:0": { "kind": "actual_in", "of": "a", "parent": "6:4" },
|
|
552
557
|
"@exit": { "kind": "exit" }
|
|
@@ -563,7 +568,7 @@ The application envelope also contains three substrate sections:
|
|
|
563
568
|
- **`artifacts`** — discovered non-code files (manifests, configs, Docker files, CI workflows,
|
|
564
569
|
packaging files, scripts, docs, and legal files) with extraction status (`none`, `partial`, or
|
|
565
570
|
`full`; default `none`), keyed by relative path; each artifact carries the
|
|
566
|
-
`can
|
|
571
|
+
`can://<app>/artifact/<path>` id namespace. Config files carry extracted `config_keys`
|
|
567
572
|
(keys, values, namespaces, and references) and `DEFINES_CONFIG` Neo4j edges.
|
|
568
573
|
- **`dependencies`** — declared packages with kind (`runtime`/`dev`/`optional`/`build`), spec,
|
|
569
574
|
locked version, and provenance (`prov`): where each binding came from (manifest file, lock file,
|
|
@@ -574,7 +579,7 @@ The application envelope also contains three substrate sections:
|
|
|
574
579
|
Notable properties:
|
|
575
580
|
|
|
576
581
|
- **Durable `can://` ids** identify every node at callable granularity and above
|
|
577
|
-
(`can
|
|
582
|
+
(`can://<app>/python/<file>/<callable-sig>`); nodes below a callable use ordinal ids
|
|
578
583
|
(`@entry`, `@exit`, `line:col`, `@formal_in:N`, `line:col/actual_in:N`).
|
|
579
584
|
- **`source` lives once per module**; every node's text is the `module.source[span.bytes]` slice.
|
|
580
585
|
- **Cross-function edges** — `call_graph`, `param_in`, `param_out`, `config_uses` — live at **application** scope;
|
|
@@ -686,13 +691,17 @@ RETURN DISTINCT c.id
|
|
|
686
691
|
MATCH (m:PyCallable {is_entrypoint: true})
|
|
687
692
|
RETURN m.id, m.entrypoint_frameworks
|
|
688
693
|
|
|
694
|
+
// did the entrypoint pass find anything? (no entrypoints vs. nothing detected)
|
|
695
|
+
MATCH (a:PyApplication)
|
|
696
|
+
RETURN a.entrypoint_frameworks, a.entrypoint_report_json
|
|
697
|
+
|
|
689
698
|
// data dependences into one statement (level 3+)
|
|
690
699
|
MATCH (s:PyBodyNode {id: $stmt})<-[d:PY_DDG]-(src:PyBodyNode)
|
|
691
700
|
RETURN src.id, d.var, d.prov
|
|
692
701
|
|
|
693
702
|
// interprocedural flow through a parameter (level 4)
|
|
694
703
|
MATCH (a:PyBodyNode)-[:PY_PARAM_IN]->(f:PyBodyNode)
|
|
695
|
-
WHERE f.id STARTS WITH "can://python/
|
|
704
|
+
WHERE f.id STARTS WITH "can://myapp/python/src/api.py"
|
|
696
705
|
RETURN a.id, f.id
|
|
697
706
|
```
|
|
698
707
|
|
|
@@ -721,3 +730,25 @@ RETURN f.id, l.version
|
|
|
721
730
|
## License
|
|
722
731
|
|
|
723
732
|
Apache 2.0 — see [LICENSE](./LICENSE).
|
|
733
|
+
|
|
734
|
+
## Polyglot applications: all languages, or none
|
|
735
|
+
|
|
736
|
+
A `--emit neo4j` push is **destructive**. It sweeps everything under `can://<app>/` that this
|
|
737
|
+
analyzer marked, then rewrites what it found. Since the id grammar puts the application outermost,
|
|
738
|
+
every analyzer over the same `<app>` shares that prefix — so a push reclaims stale rows belonging to
|
|
739
|
+
*this* analyzer and, in the shared namespaces, sweeps rows a sibling wrote.
|
|
740
|
+
|
|
741
|
+
For most of what is shared that is harmless: the artifact walk is a whole-repo inventory, so an
|
|
742
|
+
`:Artifact` a sibling wrote is re-created by this push (with a thinner view of it — `roles` falls
|
|
743
|
+
back to `unknown` and its config keys and dependency edges are gone until that sibling pushes
|
|
744
|
+
again). `@external` ghosts are not inventoried that way: they are per-language, so a sibling's
|
|
745
|
+
ghosts are swept and not restored.
|
|
746
|
+
|
|
747
|
+
**So for an application analysed in more than one language, run every analyzer or none.** Running
|
|
748
|
+
one in isolation leaves the others' derived rows missing until they run again. Running them
|
|
749
|
+
together is always correct, in any order, because the last push restores everything the batch
|
|
750
|
+
swept.
|
|
751
|
+
|
|
752
|
+
Nothing here corrupts a graph: what is lost is derived and regenerates. But a partial run leaves a
|
|
753
|
+
partial answer, and nothing in the data says so.
|
|
754
|
+
|
|
@@ -426,7 +426,7 @@ levels are cumulative and additive — `analysis.json(-a 1) ⊆ … ⊆ analysis
|
|
|
426
426
|
| **1** | `-a 1` (default) | Symbol table, Jedi call graph, and `call` nodes in each callable's `body` | `body` calls (`callee: null`) |
|
|
427
427
|
| **2** | `-a 2` | Defuse-linker call-graph enrichment; each call's `callee` backfilled to a `can://` id | `call_graph`, `body` callees |
|
|
428
428
|
| **3** | `-a 3` | Native **intraprocedural** CFG/CDG/DDG (syntactic, name-equality, `prov: ["ssa"]`) | `cfg`, `cdg`, `ddg`, `@entry`/`@exit` on each callable |
|
|
429
|
-
| **4** | `-a 4` | **Interprocedural** SDG: synthetic param vertices, alias-aware DDG (`prov: ["points-to"]`) | `param_in`, `param_out`, `summary`, semantic `ddg` |
|
|
429
|
+
| **4** | `-a 4` | **Interprocedural** SDG: synthetic param vertices, alias-aware DDG (`prov: ["points-to"]`), port-wiring DDG between statements and param vertices (`prov: ["reaching-defs"]`) | `param_in`, `param_out`, `summary`, semantic `ddg` |
|
|
430
430
|
|
|
431
431
|
`-a 1`/`-a 2` timings and output are unaffected by the heavier levels — nothing at level 3+ runs
|
|
432
432
|
unless requested. Flag gating: `--graphs sdg` requires `-a 4`; `--graphs cfg,dfg,pdg` and
|
|
@@ -448,7 +448,11 @@ symbol-table signature by construction
|
|
|
448
448
|
- **Points-to oracle (level 4):** the **Scalpel** may-alias oracle — `ScalpelAliasOracle`
|
|
449
449
|
(`codeanalyzer/dataflow/scalpel_oracle.py`) — consumes Scalpel's SSA copy/const facts to answer
|
|
450
450
|
`may_alias(path_a, path_b)`, adding the alias-aware DDG edges (`prov: ["points-to"]`) and the
|
|
451
|
-
interprocedural summaries.
|
|
451
|
+
interprocedural summaries. Level 4 also wires the statement-level DDG to the param
|
|
452
|
+
vertices (def → `actual_in`, `actual_out` → call site, `formal_in` → use, def → `formal_out`)
|
|
453
|
+
with `prov: ["reaching-defs"]`; without those the SDG would be two disconnected graphs. So
|
|
454
|
+
`prov` takes three values: `ssa` (syntactic, L3), `reaching-defs` (port wiring, L4) and
|
|
455
|
+
`points-to` (alias-derived, L4). Scalpel is **vendored** — a `typed_ast`-free slice built into the
|
|
452
456
|
package under `codeanalyzer/dataflow/scalpel/` — so it is the **default** level-4 oracle with no
|
|
453
457
|
external dependency to install; the analyzer falls back to the built-in `TypeBasedAliasOracle`
|
|
454
458
|
(Jedi-inferred types; unknown types conservatively alias) only when Scalpel can't resolve a
|
|
@@ -480,11 +484,11 @@ just populate more of the same tree:
|
|
|
480
484
|
"k_limit": 3, // access-path depth bound (--graph-field-depth); L3+ only
|
|
481
485
|
"analyzer": { "name": "codeanalyzer-python", "version": "1.0.0" },
|
|
482
486
|
"application": {
|
|
483
|
-
"id": "can
|
|
487
|
+
"id": "can://<app>",
|
|
484
488
|
"kind": "application",
|
|
485
489
|
"symbol_table": { // relative POSIX path → module
|
|
486
490
|
"pkg/mod.py": {
|
|
487
|
-
"id": "can
|
|
491
|
+
"id": "can://<app>/python/pkg/mod.py",
|
|
488
492
|
"kind": "module",
|
|
489
493
|
"source": "…full file text, stored once per module…",
|
|
490
494
|
"types": { "<Class>": { "id": "…", "kind": "class", "callables": { /* methods */ } } },
|
|
@@ -494,8 +498,8 @@ just populate more of the same tree:
|
|
|
494
498
|
"call_graph": [ { "src": "can://…/main(a)", "dst": "can://…/helper(x)",
|
|
495
499
|
"weight": 1, "prov": ["defuse", "jedi"] } ],
|
|
496
500
|
"external_symbols": { // imported/builtin call targets, keyed by id
|
|
497
|
-
"can
|
|
498
|
-
{ "id": "can
|
|
501
|
+
"can://<app>/@external/os/getcwd":
|
|
502
|
+
{ "id": "can://<app>/@external/os/getcwd", "kind": "external",
|
|
499
503
|
"name": "getcwd", "module": "os" }
|
|
500
504
|
},
|
|
501
505
|
"param_in": [ { "src": "can://…/main(a)@6:4/actual_in:0", "dst": "can://…/helper(x)@formal_in:0" } ],
|
|
@@ -513,7 +517,8 @@ A **callable** (function or method) carries its own CPG, keyed by node id:
|
|
|
513
517
|
"body": { // node id → node
|
|
514
518
|
"@entry": { "kind": "entry" },
|
|
515
519
|
"6:4": { "kind": "statement", "span": { … } },
|
|
516
|
-
"6:8": { "kind": "call", "span": { … },
|
|
520
|
+
"6:8": { "id": "can://…/main()@6:8", "kind": "call", "span": { … },
|
|
521
|
+
"callee": "can://…/helper(x)" }, // callee null until L2
|
|
517
522
|
"@formal_in:0": { "kind": "formal_in", "of": "a" }, // L4 param vertices
|
|
518
523
|
"6:4/actual_in:0": { "kind": "actual_in", "of": "a", "parent": "6:4" },
|
|
519
524
|
"@exit": { "kind": "exit" }
|
|
@@ -530,7 +535,7 @@ The application envelope also contains three substrate sections:
|
|
|
530
535
|
- **`artifacts`** — discovered non-code files (manifests, configs, Docker files, CI workflows,
|
|
531
536
|
packaging files, scripts, docs, and legal files) with extraction status (`none`, `partial`, or
|
|
532
537
|
`full`; default `none`), keyed by relative path; each artifact carries the
|
|
533
|
-
`can
|
|
538
|
+
`can://<app>/artifact/<path>` id namespace. Config files carry extracted `config_keys`
|
|
534
539
|
(keys, values, namespaces, and references) and `DEFINES_CONFIG` Neo4j edges.
|
|
535
540
|
- **`dependencies`** — declared packages with kind (`runtime`/`dev`/`optional`/`build`), spec,
|
|
536
541
|
locked version, and provenance (`prov`): where each binding came from (manifest file, lock file,
|
|
@@ -541,7 +546,7 @@ The application envelope also contains three substrate sections:
|
|
|
541
546
|
Notable properties:
|
|
542
547
|
|
|
543
548
|
- **Durable `can://` ids** identify every node at callable granularity and above
|
|
544
|
-
(`can
|
|
549
|
+
(`can://<app>/python/<file>/<callable-sig>`); nodes below a callable use ordinal ids
|
|
545
550
|
(`@entry`, `@exit`, `line:col`, `@formal_in:N`, `line:col/actual_in:N`).
|
|
546
551
|
- **`source` lives once per module**; every node's text is the `module.source[span.bytes]` slice.
|
|
547
552
|
- **Cross-function edges** — `call_graph`, `param_in`, `param_out`, `config_uses` — live at **application** scope;
|
|
@@ -653,13 +658,17 @@ RETURN DISTINCT c.id
|
|
|
653
658
|
MATCH (m:PyCallable {is_entrypoint: true})
|
|
654
659
|
RETURN m.id, m.entrypoint_frameworks
|
|
655
660
|
|
|
661
|
+
// did the entrypoint pass find anything? (no entrypoints vs. nothing detected)
|
|
662
|
+
MATCH (a:PyApplication)
|
|
663
|
+
RETURN a.entrypoint_frameworks, a.entrypoint_report_json
|
|
664
|
+
|
|
656
665
|
// data dependences into one statement (level 3+)
|
|
657
666
|
MATCH (s:PyBodyNode {id: $stmt})<-[d:PY_DDG]-(src:PyBodyNode)
|
|
658
667
|
RETURN src.id, d.var, d.prov
|
|
659
668
|
|
|
660
669
|
// interprocedural flow through a parameter (level 4)
|
|
661
670
|
MATCH (a:PyBodyNode)-[:PY_PARAM_IN]->(f:PyBodyNode)
|
|
662
|
-
WHERE f.id STARTS WITH "can://python/
|
|
671
|
+
WHERE f.id STARTS WITH "can://myapp/python/src/api.py"
|
|
663
672
|
RETURN a.id, f.id
|
|
664
673
|
```
|
|
665
674
|
|
|
@@ -688,3 +697,25 @@ RETURN f.id, l.version
|
|
|
688
697
|
## License
|
|
689
698
|
|
|
690
699
|
Apache 2.0 — see [LICENSE](./LICENSE).
|
|
700
|
+
|
|
701
|
+
## Polyglot applications: all languages, or none
|
|
702
|
+
|
|
703
|
+
A `--emit neo4j` push is **destructive**. It sweeps everything under `can://<app>/` that this
|
|
704
|
+
analyzer marked, then rewrites what it found. Since the id grammar puts the application outermost,
|
|
705
|
+
every analyzer over the same `<app>` shares that prefix — so a push reclaims stale rows belonging to
|
|
706
|
+
*this* analyzer and, in the shared namespaces, sweeps rows a sibling wrote.
|
|
707
|
+
|
|
708
|
+
For most of what is shared that is harmless: the artifact walk is a whole-repo inventory, so an
|
|
709
|
+
`:Artifact` a sibling wrote is re-created by this push (with a thinner view of it — `roles` falls
|
|
710
|
+
back to `unknown` and its config keys and dependency edges are gone until that sibling pushes
|
|
711
|
+
again). `@external` ghosts are not inventoried that way: they are per-language, so a sibling's
|
|
712
|
+
ghosts are swept and not restored.
|
|
713
|
+
|
|
714
|
+
**So for an application analysed in more than one language, run every analyzer or none.** Running
|
|
715
|
+
one in isolation leaves the others' derived rows missing until they run again. Running them
|
|
716
|
+
together is always correct, in any order, because the last push restores everything the batch
|
|
717
|
+
swept.
|
|
718
|
+
|
|
719
|
+
Nothing here corrupts a graph: what is lost is derived and regenerates. But a partial run leaves a
|
|
720
|
+
partial answer, and nothing in the data says so.
|
|
721
|
+
|
|
@@ -19,6 +19,7 @@ from codeanalyzer.schema import (
|
|
|
19
19
|
model_validate_json,
|
|
20
20
|
)
|
|
21
21
|
from codeanalyzer.schema.assign_ids import assign_ids
|
|
22
|
+
from codeanalyzer.schema.ids import external_id
|
|
22
23
|
from codeanalyzer.schema.l1_body import populate_l1_body
|
|
23
24
|
from codeanalyzer.schema.l2_callees import backfill_callees
|
|
24
25
|
from codeanalyzer.schema.call_graph_ids import reidentify_call_graph
|
|
@@ -564,8 +565,7 @@ class Codeanalyzer:
|
|
|
564
565
|
if sig in sig_to_id:
|
|
565
566
|
continue
|
|
566
567
|
module, name = sig.rsplit(".", 1) if "." in sig else (None, sig)
|
|
567
|
-
ext_id =
|
|
568
|
-
f"{app_id}/@external/{name}"
|
|
568
|
+
ext_id = external_id(app_id, module, name)
|
|
569
569
|
sig_to_id[sig] = ext_id
|
|
570
570
|
externals[ext_id] = PyExternalSymbol(
|
|
571
571
|
id=ext_id, name=name, module=module
|
|
@@ -43,6 +43,7 @@ from codeanalyzer.dataflow.alias import TypeBasedAliasOracle
|
|
|
43
43
|
from codeanalyzer.dataflow.pdg import build_pdg
|
|
44
44
|
from codeanalyzer.dataflow.sdg import ProgramGraphsIR, assemble_sdg
|
|
45
45
|
from codeanalyzer.dataflow.summaries import CallSite, FunctionInfo, compute_summaries
|
|
46
|
+
from codeanalyzer.schema.ids import stamp_body_ids
|
|
46
47
|
from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass, PyModule
|
|
47
48
|
from codeanalyzer.utils import logger
|
|
48
49
|
|
|
@@ -333,6 +334,7 @@ def emit_l3_body(
|
|
|
333
334
|
for e in pdg.edges
|
|
334
335
|
if e.type == "DDG"
|
|
335
336
|
]
|
|
337
|
+
stamp_body_ids(pycallable)
|
|
336
338
|
|
|
337
339
|
|
|
338
340
|
def build_program_graphs(
|
|
@@ -538,6 +540,7 @@ def emit_l4(
|
|
|
538
540
|
pycallable.body[im.local(pn.id)] = BodyNode(
|
|
539
541
|
kind=pn.kind, of=pn.var, parent=parent
|
|
540
542
|
)
|
|
543
|
+
stamp_body_ids(pycallable)
|
|
541
544
|
|
|
542
545
|
# (b/c/d) SDG edges → summary / param_in / param_out; CALL dropped.
|
|
543
546
|
for e in ir.sdg_edges:
|
|
@@ -16,6 +16,8 @@ from __future__ import annotations
|
|
|
16
16
|
from collections import defaultdict
|
|
17
17
|
from typing import Dict, Iterable, Optional, Tuple
|
|
18
18
|
|
|
19
|
+
from codeanalyzer.schema.ids import global_ordinal
|
|
20
|
+
|
|
19
21
|
|
|
20
22
|
class IdentityMap:
|
|
21
23
|
def __init__(self, callable_id: str, id_to_local: Dict[int, str]):
|
|
@@ -83,9 +85,7 @@ class IdentityMap:
|
|
|
83
85
|
|
|
84
86
|
def global_id(self, node_id: int) -> str:
|
|
85
87
|
"""Fully addressable id: ``"<callable-id>@<local>"``."""
|
|
86
|
-
|
|
87
|
-
# local statements are "line:col"; bookends already carry the leading "@"
|
|
88
|
-
return f"{self._callable_id}{loc}" if loc.startswith("@") else f"{self._callable_id}@{loc}"
|
|
88
|
+
return global_ordinal(self._callable_id, self._map[node_id])
|
|
89
89
|
|
|
90
90
|
def node_ids(self) -> Iterable[int]:
|
|
91
91
|
return self._map.keys()
|
{codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/matching.py
RENAMED
|
@@ -64,7 +64,8 @@ def _compile(pattern: str) -> str:
|
|
|
64
64
|
if ch == "{":
|
|
65
65
|
j = pattern.index("}", i)
|
|
66
66
|
alts = pattern[i + 1 : j].split(",")
|
|
67
|
-
|
|
67
|
+
# `*` keeps its meaning inside an alternative, so `{route,*.route}` works.
|
|
68
|
+
out.append("(?:" + "|".join(_compile(a.strip()) for a in alts) + ")")
|
|
68
69
|
i = j + 1
|
|
69
70
|
elif ch == "*":
|
|
70
71
|
out.append(r"[^.\s]*")
|
|
@@ -93,15 +94,17 @@ def _route_of(dec, spec: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
|
93
94
|
if idx >= len(args):
|
|
94
95
|
return None
|
|
95
96
|
value = _literal(args[idx])
|
|
97
|
+
if isinstance(value, (list, tuple)):
|
|
98
|
+
value = next((v for v in value if isinstance(v, str)), None)
|
|
96
99
|
return value if isinstance(value, str) else None
|
|
97
100
|
|
|
98
101
|
|
|
99
|
-
def _methods_of(dec, spec: Optional[Dict[str, Any]]) -> List[str]:
|
|
102
|
+
def _methods_of(dec, spec: Optional[Dict[str, Any]], qualified: Optional[str] = None) -> List[str]:
|
|
100
103
|
if not spec:
|
|
101
104
|
return []
|
|
102
105
|
source = spec.get("from")
|
|
103
106
|
if source == "match_suffix":
|
|
104
|
-
verb = (dec.qualified_name or "").rsplit(".", 1)[-1]
|
|
107
|
+
verb = (qualified or dec.qualified_name or "").rsplit(".", 1)[-1]
|
|
105
108
|
return [verb.upper()]
|
|
106
109
|
if source == "keyword":
|
|
107
110
|
raw = (dec.keyword_arguments or {}).get(spec.get("name", ""))
|
|
@@ -113,12 +116,27 @@ def _methods_of(dec, spec: Optional[Dict[str, Any]]) -> List[str]:
|
|
|
113
116
|
|
|
114
117
|
|
|
115
118
|
def entrypoints_from_decorators(
|
|
116
|
-
node,
|
|
119
|
+
node,
|
|
120
|
+
framework: str,
|
|
121
|
+
rules: Iterable["DecoratorRule"],
|
|
122
|
+
resolve: Optional[Callable[[str], str]] = None,
|
|
123
|
+
on_written: bool = False,
|
|
117
124
|
) -> List[PyEntrypoint]:
|
|
125
|
+
"""``resolve`` is the module's import-table resolver (#177): when Jedi could
|
|
126
|
+
not resolve a decorator (the framework is not importable in the analysis
|
|
127
|
+
environment -- every ``--no-venv`` run), ``@http.route`` still resolves to
|
|
128
|
+
``odoo.http.route`` from ``from odoo import http`` alone, the same way base
|
|
129
|
+
classes already do. Jedi's definition path wins when it exists.
|
|
130
|
+
|
|
131
|
+
``on_written`` is the heuristic tier: rules match the decorator's spelling
|
|
132
|
+
as WRITTEN (``http.route``, ``router.post``), no resolution at all, so a
|
|
133
|
+
shape that reads as an HTTP entrypoint is recorded whether or not any
|
|
134
|
+
framework rule knows the library behind it."""
|
|
118
135
|
out: List[PyEntrypoint] = []
|
|
119
136
|
for dec in getattr(node, "decorators", []) or []:
|
|
137
|
+
qualified = dec.name if on_written else decorator_qualified_name(dec, resolve)
|
|
120
138
|
for rule in rules:
|
|
121
|
-
if not match_pattern(rule.match,
|
|
139
|
+
if not match_pattern(rule.match, qualified):
|
|
122
140
|
continue
|
|
123
141
|
out.append(
|
|
124
142
|
PyEntrypoint(
|
|
@@ -126,14 +144,25 @@ def entrypoints_from_decorators(
|
|
|
126
144
|
confidence=rule.confidence,
|
|
127
145
|
rule=rule.id,
|
|
128
146
|
ruleset=rule.origin,
|
|
129
|
-
evidence=
|
|
147
|
+
evidence=qualified,
|
|
130
148
|
route=_route_of(dec, rule.route),
|
|
131
|
-
http_methods=_methods_of(dec, rule.methods),
|
|
149
|
+
http_methods=_methods_of(dec, rule.methods, qualified),
|
|
132
150
|
)
|
|
133
151
|
)
|
|
134
152
|
return out
|
|
135
153
|
|
|
136
154
|
|
|
155
|
+
def decorator_qualified_name(dec, resolve: Optional[Callable[[str], str]]) -> Optional[str]:
|
|
156
|
+
"""Jedi's resolution, else the import-table resolution of the written
|
|
157
|
+
spelling, else ``None`` (a spelling the import table cannot map either)."""
|
|
158
|
+
if dec.qualified_name:
|
|
159
|
+
return dec.qualified_name
|
|
160
|
+
if resolve is None:
|
|
161
|
+
return None
|
|
162
|
+
resolved = resolve(dec.name)
|
|
163
|
+
return resolved if resolved != dec.name else None
|
|
164
|
+
|
|
165
|
+
|
|
137
166
|
def entrypoints_from_bases(
|
|
138
167
|
cls,
|
|
139
168
|
framework: str,
|
{codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/pipeline.py
RENAMED
|
@@ -6,11 +6,14 @@ loses flags, never the analysis.
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import builtins
|
|
9
10
|
from pathlib import Path
|
|
10
|
-
from typing import Dict, Iterable, Iterator
|
|
11
|
+
from typing import Dict, Iterable, Iterator, Set
|
|
11
12
|
|
|
12
13
|
from codeanalyzer.entrypoints.detect import detected_frameworks
|
|
13
|
-
from codeanalyzer.entrypoints.matching import
|
|
14
|
+
from codeanalyzer.entrypoints.matching import (
|
|
15
|
+
decorator_qualified_name, entrypoints_from_bases, entrypoints_from_decorators,
|
|
16
|
+
)
|
|
14
17
|
from codeanalyzer.entrypoints.rules import RuleSet, load_rules
|
|
15
18
|
from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass, PyModule
|
|
16
19
|
from codeanalyzer.utils import logger
|
|
@@ -56,18 +59,34 @@ def _run_stages(app: PyApplication, project_dir: Path, rules: RuleSet) -> None:
|
|
|
56
59
|
"""
|
|
57
60
|
for node in _walk(app):
|
|
58
61
|
node.entrypoints = []
|
|
62
|
+
app.entrypoint_report.unresolved = {}
|
|
59
63
|
|
|
60
64
|
app.entrypoint_report.rulesets = list(rules.rulesets)
|
|
61
65
|
frameworks = detected_frameworks(app, project_dir, rules)
|
|
62
66
|
app.entrypoint_report.frameworks_detected = sorted(frameworks)
|
|
63
67
|
|
|
64
68
|
names = sorted(frameworks)
|
|
69
|
+
unresolved = app.entrypoint_report.unresolved
|
|
65
70
|
for mod in app.symbol_table.values():
|
|
66
71
|
resolve = _base_resolver(mod)
|
|
72
|
+
known = _known_heads(mod)
|
|
67
73
|
for node in _walk_module(mod):
|
|
74
|
+
# #177: what neither Jedi nor the import table could name. This is
|
|
75
|
+
# the counter that makes silence visible; it was never written before.
|
|
76
|
+
# A builtin, a declared class, or a name whose head is imported is
|
|
77
|
+
# nameable and is not counted (`object`, `Exception`, `typing.Generic[T]`).
|
|
78
|
+
for dec in getattr(node, "decorators", None) or []:
|
|
79
|
+
if decorator_qualified_name(dec, resolve) is None and _unnameable(dec.name, known):
|
|
80
|
+
unresolved[dec.name] = unresolved.get(dec.name, 0) + 1
|
|
81
|
+
if isinstance(node, PyClass):
|
|
82
|
+
for base in node.base_classes or []:
|
|
83
|
+
if _unnameable(base, known):
|
|
84
|
+
unresolved[base] = unresolved.get(base, 0) + 1
|
|
68
85
|
for name in names:
|
|
69
86
|
fw = rules.frameworks[name]
|
|
70
|
-
node.entrypoints.extend(
|
|
87
|
+
node.entrypoints.extend(
|
|
88
|
+
entrypoints_from_decorators(node, name, fw.decorators, resolve)
|
|
89
|
+
)
|
|
71
90
|
if isinstance(node, PyClass) and fw.bases:
|
|
72
91
|
class_eps, method_eps = entrypoints_from_bases(
|
|
73
92
|
node, name, fw.bases, resolve
|
|
@@ -77,6 +96,34 @@ def _run_stages(app: PyApplication, project_dir: Path, rules: RuleSet) -> None:
|
|
|
77
96
|
target = (node.callables or {}).get(method_name)
|
|
78
97
|
if target is not None:
|
|
79
98
|
target.entrypoints.extend(eps)
|
|
99
|
+
# Heuristic tier: the written spelling, no framework needed. Runs
|
|
100
|
+
# last so a node a framework rule already claimed keeps one record.
|
|
101
|
+
if not node.entrypoints and rules.heuristics:
|
|
102
|
+
node.entrypoints.extend(
|
|
103
|
+
entrypoints_from_decorators(
|
|
104
|
+
node, "heuristic", rules.heuristics, resolve, on_written=True
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_BUILTIN_NAMES: Set[str] = set(dir(builtins))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _known_heads(mod: PyModule) -> Set[str]:
|
|
113
|
+
"""Names that can head a nameable spelling in this module: its declared classes
|
|
114
|
+
and every imported name or alias."""
|
|
115
|
+
heads = {cl.name for cl in (mod.types or {}).values()}
|
|
116
|
+
for imp in mod.imports or []:
|
|
117
|
+
heads.add(imp.alias or imp.name)
|
|
118
|
+
return heads
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _unnameable(written: str, known: Set[str]) -> bool:
|
|
122
|
+
"""Whether a written base/decorator spelling maps to nothing this module can
|
|
123
|
+
name: not a builtin, not a declared class, and its head is not imported.
|
|
124
|
+
Subscripts (`Generic[T]`, `dict[K, V]`) are stripped before the check."""
|
|
125
|
+
head = written.split("[", 1)[0].split(".", 1)[0].strip()
|
|
126
|
+
return bool(head) and head not in _BUILTIN_NAMES and head not in known
|
|
80
127
|
|
|
81
128
|
|
|
82
129
|
def _base_resolver(mod: PyModule):
|
|
@@ -90,7 +137,8 @@ def _base_resolver(mod: PyModule):
|
|
|
90
137
|
aliases: Dict[str, str] = {}
|
|
91
138
|
for imp in mod.imports or []:
|
|
92
139
|
original = imp.alias or imp.name
|
|
93
|
-
|
|
140
|
+
module = imp.resolved_module or imp.module
|
|
141
|
+
aliases[imp.name] = module if imp.module == original else f"{module}.{original}"
|
|
94
142
|
|
|
95
143
|
def resolve(written: str) -> str:
|
|
96
144
|
head, _, rest = written.partition(".")
|
|
@@ -22,7 +22,7 @@ _CONFIDENCE = {"declared", "certain", "heuristic"}
|
|
|
22
22
|
# blocks (Units 4-5) not implemented yet; they are deliberately absent here
|
|
23
23
|
# rather than accepted-and-ignored, so a user file using them fails loudly
|
|
24
24
|
# instead of loading clean and doing nothing.
|
|
25
|
-
_TOP_LEVEL_KEYS = {"version", "frameworks", "disable"}
|
|
25
|
+
_TOP_LEVEL_KEYS = {"version", "frameworks", "heuristics", "disable"}
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
class RulesError(Exception):
|
|
@@ -60,6 +60,10 @@ class Framework:
|
|
|
60
60
|
@dataclass
|
|
61
61
|
class RuleSet:
|
|
62
62
|
frameworks: Dict[str, Framework] = field(default_factory=dict)
|
|
63
|
+
# Framework-independent decorator rules matched on the WRITTEN spelling,
|
|
64
|
+
# confidence `heuristic` by default. They run on every node regardless of
|
|
65
|
+
# `frameworks_detected` and never double a record a framework rule made.
|
|
66
|
+
heuristics: List[DecoratorRule] = field(default_factory=list)
|
|
63
67
|
rulesets: List[str] = field(default_factory=list)
|
|
64
68
|
|
|
65
69
|
|
|
@@ -103,9 +107,16 @@ def _merge(out: RuleSet, data: Dict[str, Any], origin: str) -> None:
|
|
|
103
107
|
for raw in body.get("bases") or []:
|
|
104
108
|
fw.bases.append(_base_rule(raw, origin))
|
|
105
109
|
|
|
110
|
+
heuristics = data.get("heuristics") or {}
|
|
111
|
+
if not isinstance(heuristics, dict):
|
|
112
|
+
raise RulesError(f"{origin}: `heuristics` must be a mapping")
|
|
113
|
+
for raw in heuristics.get("decorators") or []:
|
|
114
|
+
out.heuristics.append(_decorator_rule({"confidence": "heuristic", **raw}, origin))
|
|
115
|
+
|
|
106
116
|
for fw in out.frameworks.values():
|
|
107
117
|
fw.decorators = [r for r in fw.decorators if r.id not in disabled]
|
|
108
118
|
fw.bases = [r for r in fw.bases if r.id not in disabled]
|
|
119
|
+
out.heuristics = [r for r in out.heuristics if r.id not in disabled]
|
|
109
120
|
|
|
110
121
|
|
|
111
122
|
def _disable_list(data: Dict[str, Any], origin: str) -> List[str]:
|
|
@@ -22,6 +22,24 @@ frameworks:
|
|
|
22
22
|
transitive: true
|
|
23
23
|
dispatch: [get, post, put, delete, patch]
|
|
24
24
|
|
|
25
|
+
odoo:
|
|
26
|
+
detect: [odoo]
|
|
27
|
+
decorators:
|
|
28
|
+
# `route` is a plain function in odoo/http.py, so Jedi's definition path
|
|
29
|
+
# and the import-table fallback (`from odoo import http` + `@http.route`,
|
|
30
|
+
# the shape every --no-venv run sees) both spell it `odoo.http.route`.
|
|
31
|
+
# The first positional may be one route or a list of them. Odoo serves
|
|
32
|
+
# GET and POST on a route unless `methods=` narrows it (json-typed routes
|
|
33
|
+
# are POST), so the default is both, not GET.
|
|
34
|
+
- id: odoo.route
|
|
35
|
+
match: "odoo.http.route"
|
|
36
|
+
route: {from: positional, index: 0}
|
|
37
|
+
methods: {from: keyword, name: methods, default: [GET, POST]}
|
|
38
|
+
bases:
|
|
39
|
+
- id: odoo.controller
|
|
40
|
+
match: "odoo.http.Controller"
|
|
41
|
+
transitive: true
|
|
42
|
+
|
|
25
43
|
fastapi:
|
|
26
44
|
detect: [fastapi]
|
|
27
45
|
decorators:
|
|
@@ -86,3 +104,19 @@ frameworks:
|
|
|
86
104
|
match: "django.views.generic.*"
|
|
87
105
|
transitive: true
|
|
88
106
|
dispatch: [get, post, put, patch, delete, head, options]
|
|
107
|
+
|
|
108
|
+
# Framework-independent tier. Matched on the decorator's WRITTEN spelling, never
|
|
109
|
+
# on a resolved name, so a shape that reads as an HTTP entrypoint is flagged even
|
|
110
|
+
# when the library behind it has no `frameworks:` block above (or is not
|
|
111
|
+
# importable). Confidence `heuristic`; a consumer wanting only certain hits
|
|
112
|
+
# filters on it. A node a framework rule already matched gets no heuristic record.
|
|
113
|
+
heuristics:
|
|
114
|
+
decorators:
|
|
115
|
+
- id: heuristic.http-route
|
|
116
|
+
match: "{route,*.route,*.*.route}"
|
|
117
|
+
route: {from: positional, index: 0}
|
|
118
|
+
methods: {from: keyword, name: methods}
|
|
119
|
+
- id: heuristic.http-verb
|
|
120
|
+
match: "{*,*.*}.{get,post,put,patch,delete,head,options,websocket}"
|
|
121
|
+
route: {from: positional, index: 0}
|
|
122
|
+
methods: {from: match_suffix}
|