Skip to content

Commit d35541d

Browse files
authored
Merge pull request #137 from codellm-devkit/feat/issue-27-entrypoint-detection
feat(entrypoints): framework entrypoint detection, units 1-3 (#27)
2 parents 173b93e + 4086f7a commit d35541d

28 files changed

Lines changed: 3303 additions & 6 deletions

.github/workflows/release.yml

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ permissions:
1212
discussions: write # attach the release-linked repo Discussion (Announcements)
1313

1414
jobs:
15-
release:
15+
# Exercises the `python_version < '3.11'` half of the dependency matrix (ray==2.0.0,
16+
# the older jedi/networkx/pydantic/typer pins). The release job below runs on 3.12,
17+
# where the framework integration tests can install -- on 3.10 ray==2.0.0 pins
18+
# click<=8.0.4 against celery>=5.3's click>=8.1.2 floor, so those test deps are
19+
# gated >=3.11 and would silently `importorskip` here. Gating the release on this
20+
# job keeps both halves of the matrix covered (#27).
21+
compat:
1622
runs-on: ubuntu-latest
17-
1823
steps:
1924
- name: Check out code
2025
uses: actions/checkout@v4
@@ -29,6 +34,34 @@ jobs:
2934
curl -LsSf https://astral.sh/uv/install.sh | sh
3035
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
3136
37+
- name: Sync dependencies
38+
run: uv sync --all-groups
39+
40+
- name: Run tests
41+
run: uv run pytest
42+
43+
release:
44+
needs: compat
45+
runs-on: ubuntu-latest
46+
47+
steps:
48+
- name: Check out code
49+
uses: actions/checkout@v4
50+
51+
# 3.12, not 3.10: the framework integration tests (#27) need flask/fastapi/
52+
# celery/click, which are gated `python_version >= '3.11'`. On 3.10 they
53+
# `importorskip` and the decorator-rule regression they exist to catch would
54+
# ship green. The `compat` job above keeps 3.10 covered.
55+
- name: Set up Python 3.12
56+
uses: actions/setup-python@v5
57+
with:
58+
python-version: '3.12'
59+
60+
- name: Install uv
61+
run: |
62+
curl -LsSf https://astral.sh/uv/install.sh | sh
63+
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
64+
3265
- name: Sync dependencies
3366
run: uv sync --all-groups
3467

codeanalyzer/__main__.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import sys
33
from importlib.metadata import version as _pkg_version, PackageNotFoundError
44
from pathlib import Path
5-
from typing import Optional, Annotated
5+
from typing import List, Optional, Annotated
66

77
import typer
88

@@ -299,6 +299,14 @@ def main(
299299
min=-1,
300300
),
301301
] = 50,
302+
entrypoint_rules: Annotated[
303+
Optional[List[Path]],
304+
typer.Option(
305+
"--entrypoint-rules",
306+
help="Extra entrypoint rules file (YAML). Repeatable; merges with "
307+
"the shipped rules. A malformed file is an error.",
308+
),
309+
] = None,
302310
):
303311
# Determinism: pin the interpreter hash seed before any analysis (no-op
304312
# when PYTHONHASHSEED is already set; --version exits before this).
@@ -385,10 +393,25 @@ def main(
385393
pycg_shard_timeout=pycg_shard_timeout,
386394
pycg_shard_strategy=pycg_shard_strategy,
387395
pycg_max_iter=pycg_max_iter,
396+
entrypoint_rules=tuple(entrypoint_rules or ()),
388397
)
389398

390399
_set_log_level(options.verbosity)
391400

401+
# Entrypoint rules are configuration, validated before any analysis work
402+
# starts (#122 review) -- a typo must fail in milliseconds, not after the
403+
# symbol table, venv build, Jedi and PyCG have all run. `detect_entrypoints`
404+
# loads the rules again at its own call site; that second load is cheap
405+
# and keeps the entrypoints pipeline self-contained.
406+
if options.entrypoint_rules:
407+
from codeanalyzer.entrypoints.rules import RulesError, load_rules
408+
409+
try:
410+
load_rules(options.entrypoint_rules)
411+
except RulesError as exc:
412+
logger.error(f"Invalid --entrypoint-rules: {exc}")
413+
raise typer.Exit(code=1)
414+
392415
# The schema contract is a static artifact — no project analysis required.
393416
if options.emit == EmitTarget.SCHEMA:
394417
from codeanalyzer.neo4j.emit import emit_schema

codeanalyzer/core.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,12 @@ def analyze(self) -> Analysis:
636636
backfill_callees(app, sig_to_id)
637637
reidentify_call_graph(app, sig_to_id)
638638

639+
# Entrypoints: a post-pass over the built L1 tree (#27). Runs at every
640+
# level -- entrypoints are L1 data and must not vary with -a.
641+
from codeanalyzer.entrypoints import detect_entrypoints
642+
643+
detect_entrypoints(app, self.project_dir, self.options.entrypoint_rules)
644+
639645
# L3: intraprocedural dataflow (CFG/CDG/DDG) emitted onto the v2 tree.
640646
if self.analysis_level >= 3:
641647
from codeanalyzer.dataflow.builder import (
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from codeanalyzer.entrypoints.pipeline import detect_entrypoints
2+
3+
__all__ = ["detect_entrypoints"]

codeanalyzer/entrypoints/detect.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Stage 0: which frameworks is this project actually using? (#27)
2+
3+
Gates every later stage, so a project without Celery never pays for Celery
4+
rules and cannot false-positive on a locally-defined ``shared_task``. A
5+
package counts as present if first-party source imports it OR the dependency
6+
manifest names it -- either is sufficient, since an import may be dynamic.
7+
"""
8+
from __future__ import annotations
9+
10+
import re
11+
from pathlib import Path
12+
from typing import Optional, Set
13+
14+
from codeanalyzer.entrypoints.rules import RuleSet
15+
from codeanalyzer.schema.py_schema import PyApplication
16+
17+
_REQ = re.compile(r"^\s*['\"]?([A-Za-z0-9_.\-]+)")
18+
_DEPS_START = re.compile(r"dependencies\s*=\s*\[")
19+
_TABLE_HEADER = re.compile(r"(?m)^[ \t]*\[")
20+
_PKG = re.compile(r"['\"]([A-Za-z0-9][A-Za-z0-9_.\-]*)")
21+
22+
23+
def detected_frameworks(app: PyApplication, project_dir: Path, rules: RuleSet) -> Set[str]:
24+
# `present` (imports, manifest names) and `detect:` values are both
25+
# lowercased before comparison -- manifest names were already lowercased
26+
# (PyPI/pip is case-insensitive) but imports and `detect:` were not, so
27+
# a `detect: [Flask]` user rule silently never matched a `flask` import.
28+
present = _imported_packages(app) | _manifest_packages(project_dir)
29+
return {
30+
name
31+
for name, fw in rules.frameworks.items()
32+
if any(pkg.lower() in present for pkg in (fw.detect or [name]))
33+
}
34+
35+
36+
def _imported_packages(app: PyApplication) -> Set[str]:
37+
out: Set[str] = set()
38+
for mod in app.symbol_table.values():
39+
for imp in mod.imports or []:
40+
# `from flask import Flask` puts the package in `module`, not `name`.
41+
# Prefer `module`; fall back to `name` for a bare `import flask`.
42+
spelling = (getattr(imp, "module", "") or getattr(imp, "name", "") or "")
43+
spelling = spelling.lstrip(".")
44+
if spelling:
45+
out.add(spelling.split(".", 1)[0].lower())
46+
return out
47+
48+
49+
def _manifest_packages(project_dir: Path) -> Set[str]:
50+
out: Set[str] = set()
51+
pyproject = project_dir / "pyproject.toml"
52+
if pyproject.exists():
53+
# PEP 621 `[project] dependencies = [...]` -- single- or multi-line,
54+
# possibly containing nested `[...]` extras (`celery[redis]`).
55+
span = _deps_array_span(_strip_comments(pyproject.read_text()))
56+
if span is not None:
57+
for pm in _PKG.finditer(span):
58+
out.add(pm.group(1).split("[", 1)[0].lower())
59+
requirements = project_dir / "requirements.txt"
60+
if requirements.exists():
61+
for line in requirements.read_text().splitlines():
62+
m = _REQ.match(line)
63+
if m:
64+
out.add(m.group(1).split("[", 1)[0].lower())
65+
return out
66+
67+
68+
def _strip_comments(text: str) -> str:
69+
"""Drop everything from an unquoted ``#`` to end of line.
70+
71+
# ponytail: quote tracking resets each line, so a `#` inside a
72+
# triple-quoted string spanning lines could be mis-stripped. TOML
73+
# dependency arrays don't use those in practice; revisit if they do.
74+
"""
75+
out_lines = []
76+
for line in text.splitlines():
77+
in_str = None
78+
cut = len(line)
79+
for i, ch in enumerate(line):
80+
if in_str:
81+
if ch == in_str:
82+
in_str = None
83+
elif ch in ("'", '"'):
84+
in_str = ch
85+
elif ch == "#":
86+
cut = i
87+
break
88+
out_lines.append(line[:cut])
89+
return "\n".join(out_lines)
90+
91+
92+
def _deps_array_span(text: str) -> Optional[str]:
93+
"""Return the contents between the `dependencies = [` and its matching
94+
`]`, counting bracket depth so a nested `[...]` (extras, e.g.
95+
`celery[redis]`) doesn't close the span early.
96+
97+
Bounded by the next TOML table header (a `[` starting a line): if the
98+
array never closes before then, it's unterminated (truncated/corrupt
99+
file) and this returns None rather than harvesting quoted strings out
100+
of whatever table follows.
101+
"""
102+
m = _DEPS_START.search(text)
103+
if not m:
104+
return None
105+
boundary = _TABLE_HEADER.search(text, m.end())
106+
limit = boundary.start() if boundary else len(text)
107+
depth = 1
108+
in_str = None
109+
i = m.end()
110+
while i < limit and depth > 0:
111+
ch = text[i]
112+
if in_str:
113+
if ch == in_str:
114+
in_str = None
115+
elif ch in ("'", '"'):
116+
in_str = ch
117+
elif ch == "[":
118+
depth += 1
119+
elif ch == "]":
120+
depth -= 1
121+
i += 1
122+
if depth != 0:
123+
return None
124+
return text[m.end() : i - 1]

0 commit comments

Comments
 (0)