From f9e810dadaac9731909344407c6ccb0330cbe74c Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sat, 5 Sep 2026 14:49:19 -0400 Subject: [PATCH] feat(entrypoints): project the report to Neo4j, count what never resolved, detect odoo Three gaps from one Odoo run (#177): - :PyApplication now carries `entrypoint_frameworks` and `entrypoint_report_json`, always present, so a graph consumer can tell "no entrypoints" from "the pass found nothing". Additive catalog change. - `PyEntrypointReport.unresolved` had no writer and read `{}` on every run. It now counts, per written spelling, the decorators and base classes that neither Jedi nor the module's import table could name. - Decorator matching falls back to the module's import table when Jedi cannot resolve the decorator, the way base-class matching already did, so `from odoo import http` plus `@http.route` matches `odoo.http.route` with odoo not importable (every --no-venv run). `odoo` joins the shipped rules: `@http.route` methods and `http.Controller` subclasses. Closes #177 --- CHANGELOG.md | 16 ++++++ README.md | 4 ++ codeanalyzer/entrypoints/matching.py | 34 ++++++++++--- codeanalyzer/entrypoints/pipeline.py | 20 +++++++- codeanalyzer/entrypoints/rules.yml | 16 ++++++ codeanalyzer/neo4j/project.py | 7 +++ codeanalyzer/neo4j/schema.py | 2 + .../references/vocabulary.md | 2 +- schema.neo4j.json | 4 +- test/test_entrypoint_pipeline.py | 49 +++++++++++++++++++ test/test_v2_two_projection_agreement.py | 17 +++++++ 11 files changed, 161 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5bc9e..6862882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Both projections now name a body node the same way, so consumers stop recomposing the `can://` grammar themselves. Additive; `schema_version` and the graph contract are unchanged. +- The entrypoint report is projected to Neo4j (#177): `:PyApplication` carries + `entrypoint_frameworks` (`string[]`) and `entrypoint_report_json` (the whole + `PyEntrypointReport`), always present, so a graph consumer can tell "no + entrypoints" from "the pass found nothing". Additive graph-catalog change. +- `odoo` joins the shipped entrypoint rules: `@http.route` methods (route from + the first positional, one route or a list; methods from `methods=`, default + `GET`) and `http.Controller` subclasses. +- Decorator matching falls back to the module's import table when Jedi cannot + resolve the decorator (#177), the way base-class matching already did. So + `from odoo import http` plus `@http.route` matches `odoo.http.route` with + odoo not importable in the analysis environment, which is every `--no-venv` + run. ### Fixed @@ -28,6 +40,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `can://` id; anything else lands on an `@external` ghost with the same id shape call targets use. Relative imports resolve through `resolved_module` for entrypoint base matching too. +- `PyEntrypointReport.unresolved` is now written (#177). It had no writer at + all, so it read `{}` on every run; it now counts, per written spelling, the + decorators and base classes that neither Jedi nor the import table could + name. ## [1.4.0] - 2026-09-02 diff --git a/README.md b/README.md index 7516ee8..fb4da1a 100644 --- a/README.md +++ b/README.md @@ -658,6 +658,10 @@ RETURN DISTINCT c.id MATCH (m:PyCallable {is_entrypoint: true}) RETURN m.id, m.entrypoint_frameworks +// did the entrypoint pass find anything? (no entrypoints vs. nothing detected) +MATCH (a:PyApplication) +RETURN a.entrypoint_frameworks, a.entrypoint_report_json + // data dependences into one statement (level 3+) MATCH (s:PyBodyNode {id: $stmt})<-[d:PY_DDG]-(src:PyBodyNode) RETURN src.id, d.var, d.prov diff --git a/codeanalyzer/entrypoints/matching.py b/codeanalyzer/entrypoints/matching.py index 91857e7..dbbbf7b 100644 --- a/codeanalyzer/entrypoints/matching.py +++ b/codeanalyzer/entrypoints/matching.py @@ -93,15 +93,17 @@ def _route_of(dec, spec: Optional[Dict[str, Any]]) -> Optional[str]: if idx >= len(args): return None value = _literal(args[idx]) + if isinstance(value, (list, tuple)): + value = next((v for v in value if isinstance(v, str)), None) return value if isinstance(value, str) else None -def _methods_of(dec, spec: Optional[Dict[str, Any]]) -> List[str]: +def _methods_of(dec, spec: Optional[Dict[str, Any]], qualified: Optional[str] = None) -> List[str]: if not spec: return [] source = spec.get("from") if source == "match_suffix": - verb = (dec.qualified_name or "").rsplit(".", 1)[-1] + verb = (qualified or dec.qualified_name or "").rsplit(".", 1)[-1] return [verb.upper()] if source == "keyword": raw = (dec.keyword_arguments or {}).get(spec.get("name", "")) @@ -113,12 +115,21 @@ def _methods_of(dec, spec: Optional[Dict[str, Any]]) -> List[str]: def entrypoints_from_decorators( - node, framework: str, rules: Iterable["DecoratorRule"] + node, + framework: str, + rules: Iterable["DecoratorRule"], + resolve: Optional[Callable[[str], str]] = None, ) -> List[PyEntrypoint]: + """``resolve`` is the module's import-table resolver (#177): when Jedi could + not resolve a decorator (the framework is not importable in the analysis + environment -- every ``--no-venv`` run), ``@http.route`` still resolves to + ``odoo.http.route`` from ``from odoo import http`` alone, the same way base + classes already do. Jedi's definition path wins when it exists.""" out: List[PyEntrypoint] = [] for dec in getattr(node, "decorators", []) or []: + qualified = decorator_qualified_name(dec, resolve) for rule in rules: - if not match_pattern(rule.match, dec.qualified_name): + if not match_pattern(rule.match, qualified): continue out.append( PyEntrypoint( @@ -126,14 +137,25 @@ def entrypoints_from_decorators( confidence=rule.confidence, rule=rule.id, ruleset=rule.origin, - evidence=dec.qualified_name, + evidence=qualified, route=_route_of(dec, rule.route), - http_methods=_methods_of(dec, rule.methods), + http_methods=_methods_of(dec, rule.methods, qualified), ) ) return out +def decorator_qualified_name(dec, resolve: Optional[Callable[[str], str]]) -> Optional[str]: + """Jedi's resolution, else the import-table resolution of the written + spelling, else ``None`` (a spelling the import table cannot map either).""" + if dec.qualified_name: + return dec.qualified_name + if resolve is None: + return None + resolved = resolve(dec.name) + return resolved if resolved != dec.name else None + + def entrypoints_from_bases( cls, framework: str, diff --git a/codeanalyzer/entrypoints/pipeline.py b/codeanalyzer/entrypoints/pipeline.py index cdf2e23..621552a 100644 --- a/codeanalyzer/entrypoints/pipeline.py +++ b/codeanalyzer/entrypoints/pipeline.py @@ -10,7 +10,9 @@ from typing import Dict, Iterable, Iterator from codeanalyzer.entrypoints.detect import detected_frameworks -from codeanalyzer.entrypoints.matching import entrypoints_from_bases, entrypoints_from_decorators +from codeanalyzer.entrypoints.matching import ( + decorator_qualified_name, entrypoints_from_bases, entrypoints_from_decorators, +) from codeanalyzer.entrypoints.rules import RuleSet, load_rules from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass, PyModule from codeanalyzer.utils import logger @@ -56,18 +58,32 @@ def _run_stages(app: PyApplication, project_dir: Path, rules: RuleSet) -> None: """ for node in _walk(app): node.entrypoints = [] + app.entrypoint_report.unresolved = {} app.entrypoint_report.rulesets = list(rules.rulesets) frameworks = detected_frameworks(app, project_dir, rules) app.entrypoint_report.frameworks_detected = sorted(frameworks) names = sorted(frameworks) + unresolved = app.entrypoint_report.unresolved for mod in app.symbol_table.values(): resolve = _base_resolver(mod) + declared = {cl.name for cl in (mod.types or {}).values()} for node in _walk_module(mod): + # #177: what neither Jedi nor the import table could name. This is + # the counter that makes silence visible; it was never written before. + for dec in getattr(node, "decorators", None) or []: + if decorator_qualified_name(dec, resolve) is None: + unresolved[dec.name] = unresolved.get(dec.name, 0) + 1 + if isinstance(node, PyClass): + for base in node.base_classes or []: + if base not in declared and resolve(base) == base: + unresolved[base] = unresolved.get(base, 0) + 1 for name in names: fw = rules.frameworks[name] - node.entrypoints.extend(entrypoints_from_decorators(node, name, fw.decorators)) + node.entrypoints.extend( + entrypoints_from_decorators(node, name, fw.decorators, resolve) + ) if isinstance(node, PyClass) and fw.bases: class_eps, method_eps = entrypoints_from_bases( node, name, fw.bases, resolve diff --git a/codeanalyzer/entrypoints/rules.yml b/codeanalyzer/entrypoints/rules.yml index 63cd5b5..7ba7028 100644 --- a/codeanalyzer/entrypoints/rules.yml +++ b/codeanalyzer/entrypoints/rules.yml @@ -22,6 +22,22 @@ frameworks: transitive: true dispatch: [get, post, put, delete, patch] + odoo: + detect: [odoo] + decorators: + # `route` is a plain function in odoo/http.py, so Jedi's definition path + # and the import-table fallback (`from odoo import http` + `@http.route`, + # the shape every --no-venv run sees) both spell it `odoo.http.route`. + # The first positional may be one route or a list of them. + - id: odoo.route + match: "odoo.http.route" + route: {from: positional, index: 0} + methods: {from: keyword, name: methods, default: [GET]} + bases: + - id: odoo.controller + match: "odoo.http.Controller" + transitive: true + fastapi: detect: [fastapi] decorators: diff --git a/codeanalyzer/neo4j/project.py b/codeanalyzer/neo4j/project.py index 5d426f2..a1d0928 100644 --- a/codeanalyzer/neo4j/project.py +++ b/codeanalyzer/neo4j/project.py @@ -70,6 +70,13 @@ def project(app: PyApplication, app_name: str, sig_to_id: dict, "repo_uri": app.repository.uri if app.repository else None, "source_revision": app.repository.revision if app.repository else None, "repo_dirty": app.repository.dirty if app.repository else None, + # #177: the entrypoint pass under-approximates by design, so a + # graph consumer must be able to tell "no entrypoints" from "the + # pass found nothing". Always present, even when empty. + "entrypoint_frameworks": list(app.entrypoint_report.frameworks_detected), + "entrypoint_report_json": json.dumps( + app.entrypoint_report.model_dump(), sort_keys=True + ), } ), ) diff --git a/codeanalyzer/neo4j/schema.py b/codeanalyzer/neo4j/schema.py index 7daf299..be05e65 100644 --- a/codeanalyzer/neo4j/schema.py +++ b/codeanalyzer/neo4j/schema.py @@ -75,6 +75,8 @@ class RelType: "repo_uri": "string", "source_revision": "string", "repo_dirty": "boolean", + "entrypoint_frameworks": "string[]", + "entrypoint_report_json": "string", }, ), NodeLabel( diff --git a/docs/skills/analyzing-canpy-graphs/references/vocabulary.md b/docs/skills/analyzing-canpy-graphs/references/vocabulary.md index 4ea583c..0e3dc0a 100644 --- a/docs/skills/analyzing-canpy-graphs/references/vocabulary.md +++ b/docs/skills/analyzing-canpy-graphs/references/vocabulary.md @@ -4,7 +4,7 @@ | label | merge key | properties | | --- | --- | --- | -| `PyApplication` | `name` | analyzer_name, analyzer_version, name, repo_dirty, repo_uri, schema_version, source_revision | +| `PyApplication` | `name` | analyzer_name, analyzer_version, name, repo_dirty, repo_uri, schema_version, source_revision, entrypoint_frameworks, entrypoint_report_json | | `PyModule` | `id` | _module, content_hash, file_key, file_size, id, last_modified, module_name | | `PyClass` | `id` | _module, base_classes, code, decorators, docstring, end_line, entrypoint_frameworks, id, is_entrypoint, name, signature, start_line | | `PyCallable` | `id` | _module, accessed_symbols_json, code, code_start_line, cyclomatic_complexity, decorators, docstring, end_line, entrypoint_frameworks, id, is_entrypoint, modifiers, name, parameters_json, path, return_type | diff --git a/schema.neo4j.json b/schema.neo4j.json index df277ca..5d3ac6a 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -14,7 +14,9 @@ "analyzer_version": "string", "repo_uri": "string", "source_revision": "string", - "repo_dirty": "boolean" + "repo_dirty": "boolean", + "entrypoint_frameworks": "string[]", + "entrypoint_report_json": "string" } }, { diff --git a/test/test_entrypoint_pipeline.py b/test/test_entrypoint_pipeline.py index 7c6172b..2edbb61 100644 --- a/test/test_entrypoint_pipeline.py +++ b/test/test_entrypoint_pipeline.py @@ -193,3 +193,52 @@ def test_running_the_pass_twice_does_not_duplicate_entrypoints(tmp_path: Path): detect_entrypoints(app, tmp_path) second = [model_dump(e) for e in fn.entrypoints] assert second == first + + +# ---------------------------------------------------------------------------------------------- +# #177: the report counts what failed to resolve, decorators fall back to the import table, and +# odoo controllers are a shipped framework. +# ---------------------------------------------------------------------------------------------- + + +def _odoo_module(): + from codeanalyzer.schema.py_schema import PyCallable, PyClass, PyDecorator, PyImport, PyModule + + index = PyCallable(name="index", path="c.py", signature="c.Ctl.index") + index.decorators.append(PyDecorator(name="http.route", qualified_name=None, + positional_arguments=['"/x"'], + keyword_arguments={"auth": '"public"'})) + ctl = PyClass(name="Ctl", signature="c.Ctl", base_classes=["http.Controller"], + callables={"index": index}) + mystery = PyCallable(name="m", path="c.py", signature="c.m") + mystery.decorators.append(PyDecorator(name="whatever.deco", qualified_name=None)) + orphan = PyClass(name="O", signature="c.O", base_classes=["Nowhere"]) + return PyModule( + file_path="c.py", module_name="c", + imports=[PyImport(module="odoo", name="http")], + types={"Ctl": ctl, "O": orphan}, functions={"m": mystery}, + ) + + +def test_odoo_controller_detected_through_import_table_fallback(tmp_path: Path): + mod = _odoo_module() + app = PyApplication(symbol_table={"c.py": mod}) + detect_entrypoints(app, tmp_path) + assert "odoo" in app.entrypoint_report.frameworks_detected + index = mod.types["Ctl"].callables["index"] + assert [e.rule for e in index.entrypoints] == ["odoo.route"] + assert index.entrypoints[0].route == "/x" + assert index.entrypoints[0].http_methods == ["GET"] + assert index.entrypoints[0].evidence == "odoo.http.route" + assert [e.rule for e in mod.types["Ctl"].entrypoints] == ["odoo.controller"] + + +def test_report_counts_decorators_and_bases_nothing_resolves(tmp_path: Path): + mod = _odoo_module() + app = PyApplication(symbol_table={"c.py": mod}) + detect_entrypoints(app, tmp_path) + # `http.route` resolved through the import table, so it is NOT unresolved; + # `whatever.deco` and base `Nowhere` map to nothing anywhere. + assert app.entrypoint_report.unresolved == {"whatever.deco": 1, "Nowhere": 1} + detect_entrypoints(app, tmp_path) # idempotent on a warm cache + assert app.entrypoint_report.unresolved == {"whatever.deco": 1, "Nowhere": 1} diff --git a/test/test_v2_two_projection_agreement.py b/test/test_v2_two_projection_agreement.py index ec6cf85..2236db5 100644 --- a/test/test_v2_two_projection_agreement.py +++ b/test/test_v2_two_projection_agreement.py @@ -323,3 +323,20 @@ def test_py_extends_targets_declared_base_by_can_id_and_external_by_ghost(tmp_pa assert len(ext) == 3 ghosts = {n.value for n in rows.nodes if "PyExternal" in n.labels} assert "can://python/app/@external/lib.core/Thing" in ghosts + + +def test_pyapplication_carries_the_entrypoint_report(): + # #177: a Neo4j consumer can tell "no entrypoints" from "the pass found nothing". + from codeanalyzer.schema.py_schema import PyEntrypointReport + import json as _json + app = PyApplication(symbol_table={}, entrypoint_report=PyEntrypointReport( + frameworks_detected=["flask"], rulesets=["shipped"], unresolved={"x.y": 2}, errors=[])) + rows = project(app, "app", {}) + node = next(n for n in rows.nodes if n.labels[0] == "PyApplication") + assert node.props["entrypoint_frameworks"] == ["flask"] + assert _json.loads(node.props["entrypoint_report_json"]) == { + "frameworks_detected": ["flask"], "rulesets": ["shipped"], "unresolved": {"x.y": 2}, "errors": []} + # an empty report is still present — absence would be indistinguishable from silence + rows = project(PyApplication(symbol_table={}), "app", {}) + node = next(n for n in rows.nodes if n.labels[0] == "PyApplication") + assert "entrypoint_report_json" in node.props