codeanalyzer-python 1.1.1__py3-none-any.whl → 1.3.0__py3-none-any.whl
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/__main__.py +119 -118
- codeanalyzer/artifacts/__init__.py +20 -0
- codeanalyzer/artifacts/config_keys.py +588 -0
- codeanalyzer/artifacts/config_use.py +597 -0
- codeanalyzer/artifacts/config_use_rules.yml +58 -0
- codeanalyzer/artifacts/dependencies.py +237 -0
- codeanalyzer/artifacts/discovery.py +167 -0
- codeanalyzer/artifacts/parsers.py +248 -0
- codeanalyzer/core.py +112 -45
- codeanalyzer/dataflow/access_paths.py +26 -4
- codeanalyzer/dataflow/builder.py +22 -1
- codeanalyzer/dataflow/identity.py +1 -1
- codeanalyzer/dataflow/pdg.py +7 -2
- codeanalyzer/dataflow/scc.py +1 -1
- codeanalyzer/entrypoints/__init__.py +3 -0
- codeanalyzer/entrypoints/detect.py +124 -0
- codeanalyzer/entrypoints/matching.py +182 -0
- codeanalyzer/entrypoints/pipeline.py +131 -0
- codeanalyzer/entrypoints/rules.py +159 -0
- codeanalyzer/entrypoints/rules.yml +88 -0
- codeanalyzer/neo4j/bolt.py +1 -1
- codeanalyzer/neo4j/project.py +277 -60
- codeanalyzer/neo4j/schema.py +92 -34
- codeanalyzer/options/__init__.py +2 -2
- codeanalyzer/options/options.py +7 -26
- codeanalyzer/schema/__init__.py +48 -0
- codeanalyzer/schema/ids.py +21 -0
- codeanalyzer/schema/l1_body.py +11 -1
- codeanalyzer/schema/l2_callees.py +29 -13
- codeanalyzer/schema/py_schema.py +213 -103
- codeanalyzer/semantic_analysis/call_graph.py +20 -4
- codeanalyzer/semantic_analysis/defuse_linker.py +1499 -0
- codeanalyzer/syntactic_analysis/symbol_table_builder.py +99 -3
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/METADATA +143 -164
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/RECORD +39 -31
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/WHEEL +1 -1
- codeanalyzer/config/__init__.py +0 -3
- codeanalyzer/config/config.py +0 -8
- codeanalyzer/semantic_analysis/pycg/__init__.py +0 -20
- codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +0 -1115
- codeanalyzer/semantic_analysis/pycg/pycg_exceptions.py +0 -23
- codeanalyzer/semantic_analysis/pycg/shard_planner.py +0 -401
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-1.1.1.dist-info → codeanalyzer_python-1.3.0.dist-info}/licenses/NOTICE +0 -0
codeanalyzer/__main__.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import os
|
|
2
3
|
import sys
|
|
3
4
|
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
|
4
5
|
from pathlib import Path
|
|
5
|
-
from typing import Optional, Annotated
|
|
6
|
+
from typing import List, Optional, Annotated
|
|
6
7
|
|
|
7
8
|
import typer
|
|
8
9
|
|
|
@@ -10,7 +11,7 @@ import typer
|
|
|
10
11
|
def _pin_hash_seed() -> None:
|
|
11
12
|
"""Re-exec once with ``PYTHONHASHSEED=0`` unless the caller pinned one.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
Jedi's inference (and any hash-ordered iteration in the pipeline) walks sets
|
|
14
15
|
keyed on module/access-path strings, so an unpinned per-interpreter hash
|
|
15
16
|
seed makes the emitted L2+ call graph vary run to run (issue #99). The
|
|
16
17
|
seed cannot be set after interpreter start, hence the exec. Export
|
|
@@ -37,9 +38,8 @@ def _pin_hash_seed() -> None:
|
|
|
37
38
|
|
|
38
39
|
from codeanalyzer.core import Codeanalyzer
|
|
39
40
|
from codeanalyzer.utils import _set_log_level, logger
|
|
40
|
-
from codeanalyzer.
|
|
41
|
-
from codeanalyzer.
|
|
42
|
-
from codeanalyzer.options import AnalysisOptions, EmitTarget, ShardStrategy
|
|
41
|
+
from codeanalyzer.schema import model_dump, model_dump_json, strip_internal_only
|
|
42
|
+
from codeanalyzer.options import AnalysisOptions, EmitTarget
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def _version_callback(value: bool) -> None:
|
|
@@ -80,15 +80,6 @@ def main(
|
|
|
80
80
|
Optional[Path],
|
|
81
81
|
typer.Option("-o", "--output", help="Output directory for artifacts."),
|
|
82
82
|
] = None,
|
|
83
|
-
format: Annotated[
|
|
84
|
-
OutputFormat,
|
|
85
|
-
typer.Option(
|
|
86
|
-
"-f",
|
|
87
|
-
"--format",
|
|
88
|
-
help="Output format for --emit json: json or msgpack.",
|
|
89
|
-
case_sensitive=False,
|
|
90
|
-
),
|
|
91
|
-
] = OutputFormat.JSON,
|
|
92
83
|
emit: Annotated[
|
|
93
84
|
EmitTarget,
|
|
94
85
|
typer.Option(
|
|
@@ -141,26 +132,31 @@ def main(
|
|
|
141
132
|
),
|
|
142
133
|
] = None,
|
|
143
134
|
analysis_level: Annotated[
|
|
144
|
-
int,
|
|
135
|
+
Optional[int],
|
|
145
136
|
typer.Option(
|
|
146
137
|
"-a",
|
|
147
138
|
"--analysis-level",
|
|
148
|
-
help="Analysis depth: 1=symbol table+Jedi call graph, 2=+
|
|
139
|
+
help="Analysis depth: 1=symbol table+Jedi call graph, 2=+defuse-linker call "
|
|
149
140
|
"graph, 3=+native intraprocedural dataflow (CFG/PDG), "
|
|
150
|
-
"4=+interprocedural SDG (param/summary edges, alias-aware DDG)."
|
|
141
|
+
"4=+interprocedural SDG (param/summary edges, alias-aware DDG). "
|
|
142
|
+
"[default: 1; incompatible with --emit neo4j, which is always "
|
|
143
|
+
"full-depth]",
|
|
151
144
|
min=1,
|
|
152
145
|
max=4,
|
|
146
|
+
show_default="1",
|
|
153
147
|
),
|
|
154
|
-
] =
|
|
148
|
+
] = None,
|
|
155
149
|
graphs: Annotated[
|
|
156
|
-
str,
|
|
150
|
+
Optional[str],
|
|
157
151
|
typer.Option(
|
|
158
152
|
"--graphs",
|
|
159
153
|
help="Level 3+ only: comma-separated program-graph sections to emit "
|
|
160
154
|
"(cfg, dfg, pdg, sdg). Default: cfg,dfg,pdg. `dfg` emits the PDG's data "
|
|
161
|
-
"edges only; `sdg` requires -a 4."
|
|
155
|
+
"edges only; `sdg` requires -a 4. Incompatible with --emit neo4j "
|
|
156
|
+
"(always full-depth).",
|
|
157
|
+
show_default="cfg,dfg,pdg",
|
|
162
158
|
),
|
|
163
|
-
] =
|
|
159
|
+
] = None,
|
|
164
160
|
graph_field_depth: Annotated[
|
|
165
161
|
int,
|
|
166
162
|
typer.Option(
|
|
@@ -197,6 +193,14 @@ def main(
|
|
|
197
193
|
"imports against the ambient Python environment instead.",
|
|
198
194
|
),
|
|
199
195
|
] = False,
|
|
196
|
+
resolve_installed: Annotated[
|
|
197
|
+
bool,
|
|
198
|
+
typer.Option(
|
|
199
|
+
"--resolve-installed",
|
|
200
|
+
help="Additionally bind imports via the project venv's installed metadata "
|
|
201
|
+
"(*.dist-info); output becomes machine-dependent (prov: installed-metadata).",
|
|
202
|
+
),
|
|
203
|
+
] = False,
|
|
200
204
|
file_name: Annotated[
|
|
201
205
|
Optional[Path],
|
|
202
206
|
typer.Option(
|
|
@@ -222,87 +226,72 @@ def main(
|
|
|
222
226
|
verbosity: Annotated[
|
|
223
227
|
int, typer.Option("-v", count=True, help="Increase verbosity: -v, -vv, -vvv")
|
|
224
228
|
] = 0,
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
typer.Option(
|
|
228
|
-
"--pycg-shard/--no-pycg-shard",
|
|
229
|
-
help=(
|
|
230
|
-
"Shard PyCG call-graph analysis by Python package (level 2 only). "
|
|
231
|
-
"When the project exceeds the 500-file ceiling, PyCG is run "
|
|
232
|
-
"independently per top-level package with cross-package imports "
|
|
233
|
-
"treated as ghost nodes. Without this flag, projects over the "
|
|
234
|
-
"ceiling fall back to Jedi-only edges."
|
|
235
|
-
),
|
|
236
|
-
),
|
|
237
|
-
] = False,
|
|
238
|
-
pycg_shard_ceiling: Annotated[
|
|
239
|
-
int,
|
|
240
|
-
typer.Option(
|
|
241
|
-
"--pycg-shard-ceiling",
|
|
242
|
-
help=(
|
|
243
|
-
"Maximum files per shard when --pycg-shard is active (default 100). "
|
|
244
|
-
"Shards exceeding this limit are skipped; their call edges are "
|
|
245
|
-
"omitted from the call graph (Jedi edges for those packages are "
|
|
246
|
-
"still included). Lower values are safer for packages with deep "
|
|
247
|
-
"class hierarchies or heavy import graphs."
|
|
248
|
-
),
|
|
249
|
-
min=1,
|
|
250
|
-
),
|
|
251
|
-
] = 100,
|
|
252
|
-
pycg_shard_timeout: Annotated[
|
|
253
|
-
int,
|
|
229
|
+
entrypoint_rules: Annotated[
|
|
230
|
+
Optional[List[Path]],
|
|
254
231
|
typer.Option(
|
|
255
|
-
"--
|
|
256
|
-
help=(
|
|
257
|
-
|
|
258
|
-
"active (default 120). A shard that exceeds this limit is skipped "
|
|
259
|
-
"gracefully. PyCG's fixpoint is bimodal: it either converges "
|
|
260
|
-
"quickly or diverges indefinitely, so the timeout acts as a final "
|
|
261
|
-
"safety net after the file-count ceiling. Set to 0 to disable. "
|
|
262
|
-
"POSIX only (macOS / Linux); ignored on Windows."
|
|
263
|
-
),
|
|
264
|
-
min=0,
|
|
232
|
+
"--entrypoint-rules",
|
|
233
|
+
help="Extra entrypoint rules file (YAML). Repeatable; merges with "
|
|
234
|
+
"the shipped rules. A malformed file is an error.",
|
|
265
235
|
),
|
|
266
|
-
] =
|
|
267
|
-
|
|
268
|
-
|
|
236
|
+
] = None,
|
|
237
|
+
artifact_text: Annotated[
|
|
238
|
+
bool,
|
|
269
239
|
typer.Option(
|
|
270
|
-
"--
|
|
271
|
-
help=
|
|
272
|
-
|
|
273
|
-
"partitions the Jedi module-dependency graph (SCC + Louvain) so "
|
|
274
|
-
"tightly-coupled modules co-compute and few call edges are "
|
|
275
|
-
"severed between shards; import cycles are never split. "
|
|
276
|
-
"'package' uses the legacy one-shard-per-package-directory "
|
|
277
|
-
"grouping."
|
|
278
|
-
),
|
|
240
|
+
"--artifact-text/--no-artifact-text",
|
|
241
|
+
help="Capture verbatim `source` text on discovered artifacts. "
|
|
242
|
+
"--no-artifact-text empties `source` everywhere (inventory unchanged).",
|
|
279
243
|
),
|
|
280
|
-
] =
|
|
281
|
-
|
|
244
|
+
] = True,
|
|
245
|
+
artifact_text_max_bytes: Annotated[
|
|
282
246
|
int,
|
|
283
247
|
typer.Option(
|
|
284
|
-
"--
|
|
285
|
-
help=
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
"so heavy metaclass/mixin code (e.g. an ORM) can loop with each "
|
|
290
|
-
"pass costing seconds. The cap returns a sound-but-incomplete "
|
|
291
|
-
"call graph instead of looping until the timeout kills it. "
|
|
292
|
-
"Set to -1 for PyCG's unbounded run-to-convergence behaviour."
|
|
293
|
-
),
|
|
294
|
-
min=-1,
|
|
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,
|
|
295
253
|
),
|
|
296
|
-
] =
|
|
254
|
+
] = 262144,
|
|
297
255
|
):
|
|
298
256
|
# Determinism: pin the interpreter hash seed before any analysis (no-op
|
|
299
257
|
# when PYTHONHASHSEED is already set; --version exits before this).
|
|
300
258
|
_pin_hash_seed()
|
|
301
259
|
|
|
302
260
|
# Flag validation (strict: unrecognized values error out, never fall back).
|
|
303
|
-
|
|
261
|
+
# -a and --graphs use None sentinels so an explicitly-passed flag is
|
|
262
|
+
# distinguishable from the default (#119).
|
|
263
|
+
explicit_level = analysis_level is not None
|
|
264
|
+
explicit_graphs = graphs is not None
|
|
265
|
+
|
|
266
|
+
# Neo4j is always full-depth (#119): the graph carries every level's
|
|
267
|
+
# facts, so depth/section selectors cannot be combined with it — reject
|
|
268
|
+
# explicitly-passed flags and force level 4 with every graph section.
|
|
304
269
|
from codeanalyzer.dataflow.builder import VALID_GRAPHS
|
|
305
270
|
|
|
271
|
+
if emit == EmitTarget.NEO4J:
|
|
272
|
+
explicit = [
|
|
273
|
+
flag
|
|
274
|
+
for flag, was_explicit in (
|
|
275
|
+
("-a/--analysis-level", explicit_level),
|
|
276
|
+
("--graphs", explicit_graphs),
|
|
277
|
+
)
|
|
278
|
+
if was_explicit
|
|
279
|
+
]
|
|
280
|
+
if explicit:
|
|
281
|
+
logger.error(
|
|
282
|
+
"--emit neo4j is always full-depth (level 4, all graph "
|
|
283
|
+
f"sections); {' and '.join(explicit)} cannot be combined with it."
|
|
284
|
+
)
|
|
285
|
+
raise typer.Exit(code=2)
|
|
286
|
+
analysis_level = 4
|
|
287
|
+
graphs = ",".join(VALID_GRAPHS)
|
|
288
|
+
|
|
289
|
+
if analysis_level is None:
|
|
290
|
+
analysis_level = 1
|
|
291
|
+
if graphs is None:
|
|
292
|
+
graphs = "cfg,dfg,pdg"
|
|
293
|
+
selected_graphs = [g.strip() for g in graphs.split(",") if g.strip()]
|
|
294
|
+
|
|
306
295
|
unknown_graphs = [g for g in selected_graphs if g not in VALID_GRAPHS]
|
|
307
296
|
if unknown_graphs:
|
|
308
297
|
logger.error(
|
|
@@ -316,7 +305,7 @@ def main(
|
|
|
316
305
|
if "sdg" in selected_graphs and analysis_level < 4:
|
|
317
306
|
logger.error("--graphs sdg requires -a 4 (interprocedural SDG).")
|
|
318
307
|
raise typer.Exit(code=2)
|
|
319
|
-
if analysis_level < 3 and
|
|
308
|
+
if analysis_level < 3 and explicit_graphs:
|
|
320
309
|
logger.error("--graphs is a level-3 option; pass -a 3 to emit program graphs.")
|
|
321
310
|
raise typer.Exit(code=2)
|
|
322
311
|
if analysis_level < 3 and graph_field_depth != 3:
|
|
@@ -326,7 +315,7 @@ def main(
|
|
|
326
315
|
options = AnalysisOptions(
|
|
327
316
|
input=input,
|
|
328
317
|
output=output,
|
|
329
|
-
|
|
318
|
+
|
|
330
319
|
emit=emit,
|
|
331
320
|
app_name=app_name,
|
|
332
321
|
neo4j_uri=neo4j_uri,
|
|
@@ -340,19 +329,32 @@ def main(
|
|
|
340
329
|
rebuild_analysis=rebuild_analysis,
|
|
341
330
|
skip_tests=skip_tests,
|
|
342
331
|
no_venv=no_venv,
|
|
332
|
+
resolve_installed=resolve_installed,
|
|
343
333
|
file_name=file_name,
|
|
344
334
|
cache_dir=cache_dir,
|
|
345
335
|
clear_cache=clear_cache,
|
|
346
336
|
verbosity=verbosity,
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
pycg_shard_strategy=pycg_shard_strategy,
|
|
351
|
-
pycg_max_iter=pycg_max_iter,
|
|
337
|
+
entrypoint_rules=tuple(entrypoint_rules or ()),
|
|
338
|
+
artifact_text=artifact_text,
|
|
339
|
+
artifact_text_max_bytes=artifact_text_max_bytes,
|
|
352
340
|
)
|
|
353
341
|
|
|
354
342
|
_set_log_level(options.verbosity)
|
|
355
343
|
|
|
344
|
+
# Entrypoint rules are configuration, validated before any analysis work
|
|
345
|
+
# starts (#122 review) -- a typo must fail in milliseconds, not after the
|
|
346
|
+
# symbol table, venv build, Jedi and the defuse linker have all run. `detect_entrypoints`
|
|
347
|
+
# loads the rules again at its own call site; that second load is cheap
|
|
348
|
+
# and keeps the entrypoints pipeline self-contained.
|
|
349
|
+
if options.entrypoint_rules:
|
|
350
|
+
from codeanalyzer.entrypoints.rules import RulesError, load_rules
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
load_rules(options.entrypoint_rules)
|
|
354
|
+
except RulesError as exc:
|
|
355
|
+
logger.error(f"Invalid --entrypoint-rules: {exc}")
|
|
356
|
+
raise typer.Exit(code=1)
|
|
357
|
+
|
|
356
358
|
# The schema contract is a static artifact — no project analysis required.
|
|
357
359
|
if options.emit == EmitTarget.SCHEMA:
|
|
358
360
|
from codeanalyzer.neo4j.emit import emit_schema
|
|
@@ -392,37 +394,36 @@ def main(
|
|
|
392
394
|
|
|
393
395
|
emit_neo4j(artifacts, options)
|
|
394
396
|
elif options.output is None:
|
|
395
|
-
print(
|
|
397
|
+
print(
|
|
398
|
+
json.dumps(
|
|
399
|
+
strip_internal_only(
|
|
400
|
+
model_dump(artifacts, mode="json", exclude_none=True)
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
)
|
|
396
404
|
else:
|
|
397
405
|
options.output.mkdir(parents=True, exist_ok=True)
|
|
398
|
-
_write_output(artifacts, options.output
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
def _write_output(artifacts, output_dir: Path
|
|
402
|
-
"""Write
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
msgpack_data = artifacts.to_msgpack_bytes()
|
|
414
|
-
with output_file.open("wb") as f:
|
|
415
|
-
f.write(msgpack_data)
|
|
416
|
-
logger.info(f"Analysis saved to {output_file}")
|
|
417
|
-
logger.info(
|
|
418
|
-
f"Compression ratio: {artifacts.get_compression_ratio():.1%} of JSON size"
|
|
419
|
-
)
|
|
406
|
+
_write_output(artifacts, options.output)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _write_output(artifacts, output_dir: Path):
|
|
410
|
+
"""Write analysis.json (the single wire format since #118)."""
|
|
411
|
+
output_file = output_dir / "analysis.json"
|
|
412
|
+
# Use Pydantic's model_dump_json() for compact output
|
|
413
|
+
# Strip internal-only fields here rather than with a field-level Pydantic
|
|
414
|
+
# `exclude`: the analysis cache shares the serializer and must keep them.
|
|
415
|
+
json_str = json.dumps(
|
|
416
|
+
strip_internal_only(model_dump(artifacts, mode="json", exclude_none=True))
|
|
417
|
+
)
|
|
418
|
+
with output_file.open("w") as f:
|
|
419
|
+
f.write(json_str)
|
|
420
|
+
logger.info(f"Analysis saved to {output_file}")
|
|
420
421
|
|
|
421
422
|
|
|
422
423
|
app = typer.Typer(
|
|
423
424
|
callback=main,
|
|
424
425
|
name="canpy",
|
|
425
|
-
help="Static Analysis on Python source code using Jedi
|
|
426
|
+
help="Static Analysis on Python source code using Jedi and Tree sitter.",
|
|
426
427
|
invoke_without_command=True,
|
|
427
428
|
no_args_is_help=True,
|
|
428
429
|
add_completion=False,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Non-code artifact capture and dependency extraction (spec 2026-08-27).
|
|
2
|
+
|
|
3
|
+
Capture never drops a file (every non-`.py` file becomes a
|
|
4
|
+
:class:`~codeanalyzer.schema.py_schema.PyArtifact`, rule-matched or not,
|
|
5
|
+
text or binary -- issue #157 follow-up); extraction is narrow (only
|
|
6
|
+
dependency manifests are parsed for meaning in this unit)."""
|
|
7
|
+
|
|
8
|
+
from codeanalyzer.artifacts.config_keys import extract_config_keys, is_config_eligible
|
|
9
|
+
from codeanalyzer.artifacts.config_use import (
|
|
10
|
+
dataflow_intra_tier, dataflow_interproc_tier, detect_config_reads, resolve_uses,
|
|
11
|
+
)
|
|
12
|
+
from codeanalyzer.artifacts.dependencies import build_dependency_view
|
|
13
|
+
from codeanalyzer.artifacts.discovery import discover_artifacts
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"discover_artifacts", "build_dependency_view",
|
|
17
|
+
"extract_config_keys", "is_config_eligible",
|
|
18
|
+
"detect_config_reads", "resolve_uses",
|
|
19
|
+
"dataflow_intra_tier", "dataflow_interproc_tier",
|
|
20
|
+
]
|