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.
Files changed (98) hide show
  1. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/PKG-INFO +42 -11
  2. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/README.md +41 -10
  3. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/core.py +2 -2
  4. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/builder.py +3 -0
  5. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/identity.py +3 -3
  6. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/matching.py +36 -7
  7. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/pipeline.py +52 -4
  8. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/rules.py +12 -1
  9. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/rules.yml +34 -0
  10. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/bolt.py +90 -57
  11. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/cypher.py +19 -5
  12. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/project.py +105 -44
  13. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/rows.py +47 -1
  14. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/schema.py +11 -25
  15. codeanalyzer_python-1.5.0/codeanalyzer/schema/ids.py +91 -0
  16. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/l1_body.py +2 -0
  17. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/py_schema.py +8 -2
  18. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/pyproject.toml +1 -1
  19. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/schema.neo4j.json +14 -20
  20. codeanalyzer_python-1.4.0/codeanalyzer/schema/ids.py +0 -44
  21. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/.gitignore +0 -0
  22. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/LICENSE +0 -0
  23. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/NOTICE +0 -0
  24. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/__init__.py +0 -0
  25. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/__main__.py +0 -0
  26. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/__init__.py +0 -0
  27. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/config_keys.py +0 -0
  28. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/config_use.py +0 -0
  29. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/config_use_rules.yml +0 -0
  30. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/dependencies.py +0 -0
  31. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/discovery.py +0 -0
  32. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/artifacts/parsers.py +0 -0
  33. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/__init__.py +0 -0
  34. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/access_paths.py +0 -0
  35. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/alias.py +0 -0
  36. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/cfg.py +0 -0
  37. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/defuse.py +0 -0
  38. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/dominance.py +0 -0
  39. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/pdg.py +0 -0
  40. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/LICENSE +0 -0
  41. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/README.md +0 -0
  42. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/SSA/__init__.py +0 -0
  43. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/SSA/const.py +0 -0
  44. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/__init__.py +0 -0
  45. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/cfg/__init__.py +0 -0
  46. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/cfg/builder.py +0 -0
  47. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/cfg/model.py +0 -0
  48. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/core/__init__.py +0 -0
  49. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/core/func_call_visitor.py +0 -0
  50. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel/core/vars_visitor.py +0 -0
  51. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scalpel_oracle.py +0 -0
  52. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/scc.py +0 -0
  53. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/sdg.py +0 -0
  54. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/slicing.py +0 -0
  55. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/summaries.py +0 -0
  56. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/dataflow/syntactic.py +0 -0
  57. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/__init__.py +0 -0
  58. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/entrypoints/detect.py +0 -0
  59. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/jedi/__init__.py +0 -0
  60. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/jedi/jedi.py +0 -0
  61. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/__init__.py +0 -0
  62. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/neo4j/emit.py +0 -0
  63. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/options/__init__.py +0 -0
  64. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/options/options.py +0 -0
  65. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/provenance.py +0 -0
  66. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/py.typed +0 -0
  67. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/__init__.py +0 -0
  68. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/assign_ids.py +0 -0
  69. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/call_graph_ids.py +0 -0
  70. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/schema/l2_callees.py +0 -0
  71. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/semantic_analysis/__init__.py +0 -0
  72. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/semantic_analysis/call_graph.py +0 -0
  73. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/semantic_analysis/defuse_linker.py +0 -0
  74. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/syntactic_analysis/__init__.py +0 -0
  75. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/syntactic_analysis/exceptions.py +0 -0
  76. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/syntactic_analysis/import_resolver.py +0 -0
  77. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/syntactic_analysis/symbol_table_builder.py +0 -0
  78. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/utils/__init__.py +0 -0
  79. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/utils/logging.py +0 -0
  80. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/codeanalyzer/utils/progress_bar.py +0 -0
  81. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/docs/handoff/README.md +0 -0
  82. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/docs/handoff/schema.neo4j.json +0 -0
  83. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/schema-uml.drawio +0 -0
  84. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/flask/README.md +0 -0
  85. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/flask/examples/celery/README.md +0 -0
  86. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/flask/requirements-skip/README.md +0 -0
  87. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/flask/src/flask/sansio/README.md +0 -0
  88. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/requests/LICENSE +0 -0
  89. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/requests/NOTICE +0 -0
  90. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/requests/README.md +0 -0
  91. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/requests/docs/_themes/LICENSE +0 -0
  92. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/requests/ext/LICENSE +0 -0
  93. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/LICENSE +0 -0
  94. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/README.md +0 -0
  95. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/properties/README.md +0 -0
  96. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/xarray/datatree_/LICENSE +0 -0
  97. {codeanalyzer_python-1.4.0 → codeanalyzer_python-1.5.0}/test/fixtures/whole_applications/xarray/xarray/datatree_/README.md +0 -0
  98. {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.4.0
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. Scalpel is **vendored** a `typed_ast`-free slice built into the
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://python/<app>",
520
+ "id": "can://<app>",
517
521
  "kind": "application",
518
522
  "symbol_table": { // relative POSIX path → module
519
523
  "pkg/mod.py": {
520
- "id": "can://python/<app>/pkg/mod.py",
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://python/<app>/@external/os/getcwd":
531
- { "id": "can://python/<app>/@external/os/getcwd", "kind": "external",
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": { … }, "callee": "can://…/helper(x)" }, // callee null until L2
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://artifact/<app>/<path>` id namespace. Config files carry extracted `config_keys`
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://python/<app>/<file>/<callable-sig>`); nodes below a callable use ordinal ids
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/myapp/src/api.py"
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. Scalpel is **vendored** a `typed_ast`-free slice built into the
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://python/<app>",
487
+ "id": "can://<app>",
484
488
  "kind": "application",
485
489
  "symbol_table": { // relative POSIX path → module
486
490
  "pkg/mod.py": {
487
- "id": "can://python/<app>/pkg/mod.py",
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://python/<app>/@external/os/getcwd":
498
- { "id": "can://python/<app>/@external/os/getcwd", "kind": "external",
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": { … }, "callee": "can://…/helper(x)" }, // callee null until L2
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://artifact/<app>/<path>` id namespace. Config files carry extracted `config_keys`
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://python/<app>/<file>/<callable-sig>`); nodes below a callable use ordinal ids
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/myapp/src/api.py"
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 = f"{app_id}/@external/{module}/{name}" if module else \
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
- loc = self._map[node_id]
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()
@@ -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
- out.append("(?:" + "|".join(re.escape(a.strip()) for a in alts) + ")")
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, framework: str, rules: Iterable["DecoratorRule"]
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, dec.qualified_name):
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=dec.qualified_name,
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,
@@ -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 entrypoints_from_bases, entrypoints_from_decorators
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(entrypoints_from_decorators(node, name, fw.decorators))
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
- aliases[imp.name] = imp.module if imp.module == original else f"{imp.module}.{original}"
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}