codeanalyzer-python 1.3.0__tar.gz → 1.4.1__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 (97) hide show
  1. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/PKG-INFO +33 -18
  2. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/README.md +32 -17
  3. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/__main__.py +7 -13
  4. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/artifacts/dependencies.py +3 -3
  5. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/artifacts/discovery.py +8 -34
  6. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/core.py +3 -4
  7. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/builder.py +10 -2
  8. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/identity.py +3 -3
  9. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/sdg.py +17 -2
  10. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/summaries.py +27 -4
  11. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/entrypoints/matching.py +36 -7
  12. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/entrypoints/pipeline.py +52 -4
  13. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/entrypoints/rules.py +12 -1
  14. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/entrypoints/rules.yml +34 -0
  15. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/neo4j/bolt.py +101 -45
  16. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/neo4j/cypher.py +10 -4
  17. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/neo4j/emit.py +3 -2
  18. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/neo4j/project.py +97 -42
  19. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/neo4j/rows.py +36 -1
  20. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/neo4j/schema.py +10 -8
  21. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/options/options.py +2 -3
  22. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/schema/ids.py +17 -0
  23. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/schema/l1_body.py +2 -0
  24. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/schema/py_schema.py +8 -3
  25. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/pyproject.toml +1 -1
  26. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/schema.neo4j.json +12 -14
  27. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/.gitignore +0 -0
  28. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/LICENSE +0 -0
  29. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/NOTICE +0 -0
  30. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/__init__.py +0 -0
  31. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/artifacts/__init__.py +0 -0
  32. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/artifacts/config_keys.py +0 -0
  33. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/artifacts/config_use.py +0 -0
  34. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/artifacts/config_use_rules.yml +0 -0
  35. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/artifacts/parsers.py +0 -0
  36. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/__init__.py +0 -0
  37. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/access_paths.py +0 -0
  38. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/alias.py +0 -0
  39. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/cfg.py +0 -0
  40. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/defuse.py +0 -0
  41. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/dominance.py +0 -0
  42. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/pdg.py +0 -0
  43. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/LICENSE +0 -0
  44. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/README.md +0 -0
  45. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/SSA/__init__.py +0 -0
  46. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/SSA/const.py +0 -0
  47. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/__init__.py +0 -0
  48. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/cfg/__init__.py +0 -0
  49. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/cfg/builder.py +0 -0
  50. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/cfg/model.py +0 -0
  51. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/core/__init__.py +0 -0
  52. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/core/func_call_visitor.py +0 -0
  53. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel/core/vars_visitor.py +0 -0
  54. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scalpel_oracle.py +0 -0
  55. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/scc.py +0 -0
  56. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/slicing.py +0 -0
  57. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/dataflow/syntactic.py +0 -0
  58. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/entrypoints/__init__.py +0 -0
  59. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/entrypoints/detect.py +0 -0
  60. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/jedi/__init__.py +0 -0
  61. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/jedi/jedi.py +0 -0
  62. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/neo4j/__init__.py +0 -0
  63. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/options/__init__.py +0 -0
  64. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/provenance.py +0 -0
  65. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/py.typed +0 -0
  66. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/schema/__init__.py +0 -0
  67. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/schema/assign_ids.py +0 -0
  68. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/schema/call_graph_ids.py +0 -0
  69. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/schema/l2_callees.py +0 -0
  70. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/semantic_analysis/__init__.py +0 -0
  71. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/semantic_analysis/call_graph.py +0 -0
  72. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/semantic_analysis/defuse_linker.py +0 -0
  73. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/syntactic_analysis/__init__.py +0 -0
  74. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/syntactic_analysis/exceptions.py +0 -0
  75. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/syntactic_analysis/import_resolver.py +0 -0
  76. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/syntactic_analysis/symbol_table_builder.py +0 -0
  77. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/utils/__init__.py +0 -0
  78. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/utils/logging.py +0 -0
  79. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/codeanalyzer/utils/progress_bar.py +0 -0
  80. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/docs/handoff/README.md +0 -0
  81. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/docs/handoff/schema.neo4j.json +0 -0
  82. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/schema-uml.drawio +0 -0
  83. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/flask/README.md +0 -0
  84. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/flask/examples/celery/README.md +0 -0
  85. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/flask/requirements-skip/README.md +0 -0
  86. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/flask/src/flask/sansio/README.md +0 -0
  87. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/requests/LICENSE +0 -0
  88. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/requests/NOTICE +0 -0
  89. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/requests/README.md +0 -0
  90. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/requests/docs/_themes/LICENSE +0 -0
  91. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/requests/ext/LICENSE +0 -0
  92. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/xarray/LICENSE +0 -0
  93. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/xarray/README.md +0 -0
  94. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/xarray/properties/README.md +0 -0
  95. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/xarray/xarray/datatree_/LICENSE +0 -0
  96. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/test/fixtures/whole_applications/xarray/xarray/datatree_/README.md +0 -0
  97. {codeanalyzer_python-1.3.0 → codeanalyzer_python-1.4.1}/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.0
3
+ Version: 1.4.1
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
@@ -99,7 +99,9 @@ needs.
99
99
  **interprocedural SDG** (synthetic parameter vertices, `param_in`/`param_out`/`summary`,
100
100
  alias-aware DDG) at level 4 — all built in-process from the stdlib `ast`.
101
101
  - **Neo4j output** — project the analysis into a labeled property graph: a self-contained
102
- `graph.cypher` snapshot, or an **incremental** push to a live database over Bolt.
102
+ `graph.cypher` snapshot, or an **incremental** push to a live database over Bolt. A push is
103
+ **additive by default** and never deletes: `--eager` is what permits it to remove declarations
104
+ and edges the source no longer has.
103
105
  - **Versioned schema** — a machine-readable, version-stamped Neo4j schema contract (`--emit schema`),
104
106
  checked in as `schema.neo4j.json` (`2.0.0`) and shipped with every release.
105
107
  - **Incremental cache** — per-file results are cached under `.codeanalyzer`; `--lazy` (default)
@@ -300,7 +302,19 @@ $ canpy --help
300
302
  │ --eager --lazy Enable eager or │
301
303
  │ lazy analysis. │
302
304
  │ Defaults to │
303
- │ lazy.
305
+ │ lazy. Also gates
306
+ │ every │
307
+ │ destructive step │
308
+ │ of a '--emit │
309
+ │ neo4j' Bolt │
310
+ │ push: a lazy │
311
+ │ push only adds │
312
+ │ and updates, an │
313
+ │ eager one also │
314
+ │ removes │
315
+ │ declarations and │
316
+ │ edges the source │
317
+ │ no longer has. │
304
318
  │ [default: lazy] │
305
319
  │ --skip-tests --include-tests Skip test files │
306
320
  │ in analysis. │
@@ -361,26 +375,18 @@ $ canpy --help
361
375
  │ `source` text on │
362
376
  │ discovered │
363
377
  │ artifacts. │
378
+ │ `source` is the │
379
+ │ whole file; │
364
380
  │ --no-artifact-t… │
365
- │ empties `source`
381
+ │ empties it
366
382
  │ everywhere │
367
383
  │ (inventory │
368
384
  │ unchanged). │
369
- │ [default: │
370
- │ artifact-text] │
371
- │ --artifact-text-… <int range> Per-file byte │
372
- │ [x>=1] cap on captured │
373
- │ artifact │
374
- │ `source`; a │
375
- │ decodable file │
376
- │ over the cap is │
377
- │ truncated │
378
- │ (text_truncated… │
379
385
  │ sha256/size_byt… │
380
386
  │ always reflect │
381
387
  │ the full file. │
382
388
  │ [default: │
383
- 262144]
389
+ artifact-text]
384
390
  │ --help Show this │
385
391
  │ message and │
386
392
  │ exit. │
@@ -453,7 +459,7 @@ levels are cumulative and additive — `analysis.json(-a 1) ⊆ … ⊆ analysis
453
459
  | **1** | `-a 1` (default) | Symbol table, Jedi call graph, and `call` nodes in each callable's `body` | `body` calls (`callee: null`) |
454
460
  | **2** | `-a 2` | Defuse-linker call-graph enrichment; each call's `callee` backfilled to a `can://` id | `call_graph`, `body` callees |
455
461
  | **3** | `-a 3` | Native **intraprocedural** CFG/CDG/DDG (syntactic, name-equality, `prov: ["ssa"]`) | `cfg`, `cdg`, `ddg`, `@entry`/`@exit` on each callable |
456
- | **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` |
457
463
 
458
464
  `-a 1`/`-a 2` timings and output are unaffected by the heavier levels — nothing at level 3+ runs
459
465
  unless requested. Flag gating: `--graphs sdg` requires `-a 4`; `--graphs cfg,dfg,pdg` and
@@ -475,7 +481,11 @@ symbol-table signature by construction
475
481
  - **Points-to oracle (level 4):** the **Scalpel** may-alias oracle — `ScalpelAliasOracle`
476
482
  (`codeanalyzer/dataflow/scalpel_oracle.py`) — consumes Scalpel's SSA copy/const facts to answer
477
483
  `may_alias(path_a, path_b)`, adding the alias-aware DDG edges (`prov: ["points-to"]`) and the
478
- 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
479
489
  package under `codeanalyzer/dataflow/scalpel/` — so it is the **default** level-4 oracle with no
480
490
  external dependency to install; the analyzer falls back to the built-in `TypeBasedAliasOracle`
481
491
  (Jedi-inferred types; unknown types conservatively alias) only when Scalpel can't resolve a
@@ -540,7 +550,8 @@ A **callable** (function or method) carries its own CPG, keyed by node id:
540
550
  "body": { // node id → node
541
551
  "@entry": { "kind": "entry" },
542
552
  "6:4": { "kind": "statement", "span": { … } },
543
- "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
544
555
  "@formal_in:0": { "kind": "formal_in", "of": "a" }, // L4 param vertices
545
556
  "6:4/actual_in:0": { "kind": "actual_in", "of": "a", "parent": "6:4" },
546
557
  "@exit": { "kind": "exit" }
@@ -680,6 +691,10 @@ RETURN DISTINCT c.id
680
691
  MATCH (m:PyCallable {is_entrypoint: true})
681
692
  RETURN m.id, m.entrypoint_frameworks
682
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
+
683
698
  // data dependences into one statement (level 3+)
684
699
  MATCH (s:PyBodyNode {id: $stmt})<-[d:PY_DDG]-(src:PyBodyNode)
685
700
  RETURN src.id, d.var, d.prov
@@ -66,7 +66,9 @@ needs.
66
66
  **interprocedural SDG** (synthetic parameter vertices, `param_in`/`param_out`/`summary`,
67
67
  alias-aware DDG) at level 4 — all built in-process from the stdlib `ast`.
68
68
  - **Neo4j output** — project the analysis into a labeled property graph: a self-contained
69
- `graph.cypher` snapshot, or an **incremental** push to a live database over Bolt.
69
+ `graph.cypher` snapshot, or an **incremental** push to a live database over Bolt. A push is
70
+ **additive by default** and never deletes: `--eager` is what permits it to remove declarations
71
+ and edges the source no longer has.
70
72
  - **Versioned schema** — a machine-readable, version-stamped Neo4j schema contract (`--emit schema`),
71
73
  checked in as `schema.neo4j.json` (`2.0.0`) and shipped with every release.
72
74
  - **Incremental cache** — per-file results are cached under `.codeanalyzer`; `--lazy` (default)
@@ -267,7 +269,19 @@ $ canpy --help
267
269
  │ --eager --lazy Enable eager or │
268
270
  │ lazy analysis. │
269
271
  │ Defaults to │
270
- │ lazy.
272
+ │ lazy. Also gates
273
+ │ every │
274
+ │ destructive step │
275
+ │ of a '--emit │
276
+ │ neo4j' Bolt │
277
+ │ push: a lazy │
278
+ │ push only adds │
279
+ │ and updates, an │
280
+ │ eager one also │
281
+ │ removes │
282
+ │ declarations and │
283
+ │ edges the source │
284
+ │ no longer has. │
271
285
  │ [default: lazy] │
272
286
  │ --skip-tests --include-tests Skip test files │
273
287
  │ in analysis. │
@@ -328,26 +342,18 @@ $ canpy --help
328
342
  │ `source` text on │
329
343
  │ discovered │
330
344
  │ artifacts. │
345
+ │ `source` is the │
346
+ │ whole file; │
331
347
  │ --no-artifact-t… │
332
- │ empties `source`
348
+ │ empties it
333
349
  │ everywhere │
334
350
  │ (inventory │
335
351
  │ unchanged). │
336
- │ [default: │
337
- │ artifact-text] │
338
- │ --artifact-text-… <int range> Per-file byte │
339
- │ [x>=1] cap on captured │
340
- │ artifact │
341
- │ `source`; a │
342
- │ decodable file │
343
- │ over the cap is │
344
- │ truncated │
345
- │ (text_truncated… │
346
352
  │ sha256/size_byt… │
347
353
  │ always reflect │
348
354
  │ the full file. │
349
355
  │ [default: │
350
- 262144]
356
+ artifact-text]
351
357
  │ --help Show this │
352
358
  │ message and │
353
359
  │ exit. │
@@ -420,7 +426,7 @@ levels are cumulative and additive — `analysis.json(-a 1) ⊆ … ⊆ analysis
420
426
  | **1** | `-a 1` (default) | Symbol table, Jedi call graph, and `call` nodes in each callable's `body` | `body` calls (`callee: null`) |
421
427
  | **2** | `-a 2` | Defuse-linker call-graph enrichment; each call's `callee` backfilled to a `can://` id | `call_graph`, `body` callees |
422
428
  | **3** | `-a 3` | Native **intraprocedural** CFG/CDG/DDG (syntactic, name-equality, `prov: ["ssa"]`) | `cfg`, `cdg`, `ddg`, `@entry`/`@exit` on each callable |
423
- | **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` |
424
430
 
425
431
  `-a 1`/`-a 2` timings and output are unaffected by the heavier levels — nothing at level 3+ runs
426
432
  unless requested. Flag gating: `--graphs sdg` requires `-a 4`; `--graphs cfg,dfg,pdg` and
@@ -442,7 +448,11 @@ symbol-table signature by construction
442
448
  - **Points-to oracle (level 4):** the **Scalpel** may-alias oracle — `ScalpelAliasOracle`
443
449
  (`codeanalyzer/dataflow/scalpel_oracle.py`) — consumes Scalpel's SSA copy/const facts to answer
444
450
  `may_alias(path_a, path_b)`, adding the alias-aware DDG edges (`prov: ["points-to"]`) and the
445
- 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
446
456
  package under `codeanalyzer/dataflow/scalpel/` — so it is the **default** level-4 oracle with no
447
457
  external dependency to install; the analyzer falls back to the built-in `TypeBasedAliasOracle`
448
458
  (Jedi-inferred types; unknown types conservatively alias) only when Scalpel can't resolve a
@@ -507,7 +517,8 @@ A **callable** (function or method) carries its own CPG, keyed by node id:
507
517
  "body": { // node id → node
508
518
  "@entry": { "kind": "entry" },
509
519
  "6:4": { "kind": "statement", "span": { … } },
510
- "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
511
522
  "@formal_in:0": { "kind": "formal_in", "of": "a" }, // L4 param vertices
512
523
  "6:4/actual_in:0": { "kind": "actual_in", "of": "a", "parent": "6:4" },
513
524
  "@exit": { "kind": "exit" }
@@ -647,6 +658,10 @@ RETURN DISTINCT c.id
647
658
  MATCH (m:PyCallable {is_entrypoint: true})
648
659
  RETURN m.id, m.entrypoint_frameworks
649
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
+
650
665
  // data dependences into one statement (level 3+)
651
666
  MATCH (s:PyBodyNode {id: $stmt})<-[d:PY_DDG]-(src:PyBodyNode)
652
667
  RETURN src.id, d.var, d.prov
@@ -175,7 +175,10 @@ def main(
175
175
  bool,
176
176
  typer.Option(
177
177
  "--eager/--lazy",
178
- help="Enable eager or lazy analysis. Defaults to lazy.",
178
+ help="Enable eager or lazy analysis. Defaults to lazy. Also gates every "
179
+ "destructive step of a '--emit neo4j' Bolt push: a lazy push only adds and "
180
+ "updates, an eager one also removes declarations and edges the source no "
181
+ "longer has.",
179
182
  ),
180
183
  ] = False,
181
184
  skip_tests: Annotated[
@@ -239,19 +242,11 @@ def main(
239
242
  typer.Option(
240
243
  "--artifact-text/--no-artifact-text",
241
244
  help="Capture verbatim `source` text on discovered artifacts. "
242
- "--no-artifact-text empties `source` everywhere (inventory unchanged).",
245
+ "`source` is the whole file; --no-artifact-text empties it "
246
+ "everywhere (inventory unchanged). sha256/size_bytes always "
247
+ "reflect the full file.",
243
248
  ),
244
249
  ] = True,
245
- artifact_text_max_bytes: Annotated[
246
- int,
247
- typer.Option(
248
- "--artifact-text-max-bytes",
249
- help="Per-file byte cap on captured artifact `source`; a decodable "
250
- "file over the cap is truncated (text_truncated=True). "
251
- "sha256/size_bytes always reflect the full file.",
252
- min=1,
253
- ),
254
- ] = 262144,
255
250
  ):
256
251
  # Determinism: pin the interpreter hash seed before any analysis (no-op
257
252
  # when PYTHONHASHSEED is already set; --version exits before this).
@@ -336,7 +331,6 @@ def main(
336
331
  verbosity=verbosity,
337
332
  entrypoint_rules=tuple(entrypoint_rules or ()),
338
333
  artifact_text=artifact_text,
339
- artifact_text_max_bytes=artifact_text_max_bytes,
340
334
  )
341
335
 
342
336
  _set_log_level(options.verbosity)
@@ -81,9 +81,9 @@ def _resolve_ref(manifest_path: str, ref: str) -> Optional[str]:
81
81
 
82
82
  def _full_text(project_dir: Path, path: str, art: PyArtifact) -> str:
83
83
  """Manifest/lock extraction must never depend on the stored ``source`` --
84
- that's capped by ``text_max_bytes`` and emptied by ``capture_text=False``
85
- (both payload-size controls on the JSON/Neo4j payload, not extraction
86
- controls). Read the real file fresh instead; fall back to ``art.source``
84
+ that's emptied by ``capture_text=False`` (a payload-size control on the
85
+ JSON/Neo4j payload, not an extraction control). Read the real file fresh
86
+ instead; fall back to ``art.source``
87
87
  only if it is gone (e.g. a synthetic artifact in a unit test, or the file
88
88
  vanished mid-run).
89
89
 
@@ -76,27 +76,11 @@ def _classify(rel_posix: str) -> Tuple[str, List[str]] | None:
76
76
  return None
77
77
 
78
78
 
79
- def _capture_source(
80
- raw: bytes, text: str, capture_text: bool, text_max_bytes: int
81
- ) -> Tuple[str, bool]:
82
- """Decide ``(source, text_truncated)`` for a decodable file.
83
-
84
- Slices ``raw`` (not ``text``) for the cap, so it is a true byte cap even
85
- when it lands inside a multi-byte character -- ``errors="ignore"`` drops
86
- the dangling partial char at the cut, so this never raises."""
87
- if not capture_text:
88
- return "", False
89
- if len(raw) <= text_max_bytes:
90
- return text, False
91
- return raw[:text_max_bytes].decode("utf-8", errors="ignore"), True
92
-
93
-
94
79
  def discover_artifacts(
95
80
  project_dir: Path,
96
81
  app_name: str,
97
82
  *,
98
83
  capture_text: bool = True,
99
- text_max_bytes: int = 262144,
100
84
  ) -> Dict[str, PyArtifact]:
101
85
  """Walk the project and return every file as an artifact, sorted by path.
102
86
 
@@ -109,15 +93,11 @@ def discover_artifacts(
109
93
  deliberate exception -- it IS rule-matched (a dependency-manifest), so it
110
94
  is captured like any other manifest despite the `.py` suffix.
111
95
 
112
- ``capture_text=False`` empties ``source`` everywhere (inventory otherwise
113
- identical); a decodable file over ``text_max_bytes`` gets a truncated
114
- ``source`` and ``text_truncated=True`` -- except a ``dependency-manifest``
115
- role artifact, which is always captured in full when decodable and
116
- ``capture_text`` is on: its source is what ``build_dependency_view``
117
- parses, not bulk/incidental content, so the byte cap does not apply to
118
- it (``capture_text=False`` still empties it like everything else).
119
- ``sha256``/``size_bytes`` always reflect the full file regardless of
120
- either knob."""
96
+ ``source`` is the WHOLE file or nothing -- never a prefix (#172). A
97
+ decodable file is captured in full; ``capture_text=False`` empties
98
+ ``source`` everywhere (inventory otherwise identical), and an undecodable
99
+ file gets ``""`` as ``binary``. ``sha256``/``size_bytes`` always reflect
100
+ the full file regardless."""
121
101
  out: Dict[str, PyArtifact] = {}
122
102
  for path in sorted(project_dir.rglob("*")):
123
103
  if not path.is_file():
@@ -148,20 +128,14 @@ def discover_artifacts(
148
128
  if decodable and "." not in name and text.startswith("#!"):
149
129
  roles = ["script"]
150
130
  if decodable:
151
- # A dependency-manifest's source IS the extracted meaning (build_
152
- # dependency_view parses it) -- the byte cap targets bulk/incidental
153
- # assets, never the files extraction depends on, so manifests are
154
- # exempt from it. capture_text=False still empties source (handled
155
- # inside _capture_source); only the byte CAP is bypassed here.
156
- cap = len(raw) if "dependency-manifest" in roles else text_max_bytes
157
- source, text_truncated = _capture_source(raw, text, capture_text, cap)
131
+ source = text if capture_text else ""
158
132
  else:
159
- fmt, source, text_truncated = "binary", "", False
133
+ fmt, source = "binary", ""
160
134
 
161
135
  out[rel_posix] = PyArtifact(
162
136
  id=artifact_id(app_name, rel_posix), path=rel_posix, format=fmt,
163
137
  roles=list(roles), size_bytes=len(raw),
164
138
  sha256=hashlib.sha256(raw).hexdigest(),
165
- source=source, text_truncated=text_truncated,
139
+ source=source,
166
140
  )
167
141
  return out
@@ -40,9 +40,9 @@ from codeanalyzer.provenance import analyzer_info, repository_info
40
40
  def _artifact_full_text(project_dir: Path, path: str, art) -> str:
41
41
  """Mirrors ``artifacts.dependencies._full_text`` verbatim (not imported
42
42
  -- that name is module-private to ``dependencies.py``): config-key
43
- extraction (#152) must never depend on the stored ``source`` -- capped
44
- by ``text_max_bytes`` and emptied by ``capture_text=False`` (payload-size
45
- controls, not extraction controls). Read the real file fresh instead;
43
+ extraction (#152) must never depend on the stored ``source`` -- emptied
44
+ by ``capture_text=False`` (a payload-size control, not an extraction
45
+ control). Read the real file fresh instead;
46
46
  fall back to ``art.source`` only if it's gone (e.g. a synthetic artifact
47
47
  in a unit test, or the file vanished mid-run). Keep the two in sync if
48
48
  this logic changes."""
@@ -673,7 +673,6 @@ class Codeanalyzer:
673
673
  app.artifacts = discover_artifacts(
674
674
  self.project_dir, app_name,
675
675
  capture_text=self.options.artifact_text,
676
- text_max_bytes=self.options.artifact_text_max_bytes,
677
676
  )
678
677
  app.dependencies, app.unresolved_imports = build_dependency_view(
679
678
  app.artifacts,
@@ -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(
@@ -442,8 +444,13 @@ def build_program_graphs(
442
444
  for t in cs.targets:
443
445
  call_edges.append((sig, t))
444
446
 
445
- summaries = compute_summaries(infos, sorted(set(call_edges)))
446
- return assemble_sdg(infos, summaries, k)
447
+ # The converged (facts, ddg) per function are threaded straight into the
448
+ # assembler rather than re-derived there (#155).
449
+ solutions: Dict[str, object] = {}
450
+ summaries = compute_summaries(
451
+ infos, sorted(set(call_edges)), solutions=solutions
452
+ )
453
+ return assemble_sdg(infos, summaries, k, solutions=solutions)
447
454
 
448
455
 
449
456
  def emit_l4(
@@ -533,6 +540,7 @@ def emit_l4(
533
540
  pycallable.body[im.local(pn.id)] = BodyNode(
534
541
  kind=pn.kind, of=pn.var, parent=parent
535
542
  )
543
+ stamp_body_ids(pycallable)
536
544
 
537
545
  # (b/c/d) SDG edges → summary / param_in / param_out; CALL dropped.
538
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()
@@ -386,8 +386,18 @@ def assemble_sdg(
386
386
  infos: Dict[str, FunctionInfo],
387
387
  summaries: Dict[str, FunctionSummary],
388
388
  k: int,
389
+ *,
390
+ solutions: Optional[Dict[str, Tuple[Dict[int, object], List[object]]]] = None,
389
391
  ) -> ProgramGraphsIR:
390
- """Stitch every function's PDG into the whole-program SDG."""
392
+ """Stitch every function's PDG into the whole-program SDG.
393
+
394
+ *solutions* optionally carries the converged ``(facts, ddg)`` that
395
+ :func:`~codeanalyzer.dataflow.summaries.compute_summaries` already
396
+ derived, sparing a second identical solve per function (#155). Omit it and
397
+ every function is re-solved, which is the historical behaviour and the
398
+ right posture whenever *summaries* did not come from an immediately
399
+ preceding run over these same *infos*.
400
+ """
391
401
  ir = ProgramGraphsIR(k_limit=k)
392
402
 
393
403
  # Pass 1: solve each function against the final summaries and lay out its
@@ -396,7 +406,12 @@ def assemble_sdg(
396
406
  formal_ids: Dict[str, Dict[str, int]] = {}
397
407
  for sig in sorted(infos):
398
408
  info = infos[sig]
399
- summary, facts, ddg = solve_function(info, summaries)
409
+ cached = solutions.get(sig) if solutions is not None else None
410
+ if cached is None:
411
+ summary, facts, ddg = solve_function(info, summaries)
412
+ else:
413
+ facts, ddg = cached
414
+ summary = summaries[sig]
400
415
  asm = _FunctionAssembler(info, summary, facts, ddg)
401
416
  asm.build_formals()
402
417
  assemblers[sig] = asm
@@ -199,19 +199,42 @@ def solve_function(
199
199
  def compute_summaries(
200
200
  infos: Dict[str, FunctionInfo],
201
201
  call_edges: List[Tuple[str, str]],
202
+ *,
203
+ solutions: Optional[Dict[str, Tuple[Dict[int, object], List[DDGEdge]]]] = None,
202
204
  ) -> Dict[str, FunctionSummary]:
203
205
  """Bottom-up composition over the SCC condensation DAG, monotone fixpoint
204
- within each SCC."""
206
+ within each SCC.
207
+
208
+ A **singleton SCC with no self-edge** is solved exactly once: the
209
+ condensation is processed bottom-up, so every callee summary it reads is
210
+ already final and a second pass could only recompute the same answer to
211
+ observe that nothing changed. Genuinely recursive SCCs (several members,
212
+ or one member calling itself) still iterate to fixpoint.
213
+
214
+ When *solutions* is supplied it receives each signature's converged
215
+ ``(facts, ddg)`` — the by-products of the final solve, which
216
+ :func:`~codeanalyzer.dataflow.sdg.assemble_sdg` would otherwise recompute
217
+ from scratch. They are the same values that a fresh solve against the
218
+ final summaries produces, because a converged pass is by definition one
219
+ in which no member's summary changed (#155).
220
+ """
205
221
  order = strongly_connected_components(sorted(infos), call_edges)
222
+ self_calls = {src for src, dst in call_edges if src == dst}
206
223
  summaries: Dict[str, FunctionSummary] = {}
207
224
  for scc in order:
208
225
  members = [s for s in scc if s in infos]
209
- changed = True
210
- while changed:
226
+ if not members:
227
+ continue
228
+ recursive = len(members) > 1 or members[0] in self_calls
229
+ while True:
211
230
  changed = False
212
231
  for sig in members:
213
- new, _, _ = solve_function(infos[sig], summaries)
232
+ new, facts, ddg = solve_function(infos[sig], summaries)
233
+ if solutions is not None:
234
+ solutions[sig] = (facts, ddg)
214
235
  if summaries.get(sig) != new:
215
236
  summaries[sig] = new
216
237
  changed = True
238
+ if not (recursive and changed):
239
+ break
217
240
  return summaries
@@ -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,