Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,69 @@ when git is unavailable or the workspace is not a git repo.
scan). This is a pre-existing gap tracked in #25 and is NOT made
worse by this change.

### Hybrid Type Resolution (issue #13)

Adds a post-AST-pass type resolution layer that uses the per-file import
registry to refine CALLS edges. Previously `user.profile.update()` was
recorded as a call to `update` with no target type — the call graph had
holes wherever methods were called on imported objects. Now the receiver
type is resolved via the import registry, and the CALLS edge's
`target_id` is refined to the correct target node (e.g. `Profile.update`
in `models.py` instead of an arbitrary `update` match).

### Added (type resolution)

- **`scripts/hybrid_type_resolver.py`** — New module with:
- `build_import_registry(workspace, db_path)` — Scans Python
`from X import Y` / `import X.Y as Z` and TS/JS
`import {Y} from 'X'` / `import * as X from 'Y'` statements. Stores
results in a new `import_registry` SQLite table
`(file, local_name, module_path, symbol_name, line)`. Also writes
IMPORTS edges to `graph_edges` (edge_type='IMPORTS') so the graph
model now carries import relationships alongside CALLS.
- `resolve_receiver_type(file_path, receiver_expr, import_registry)` —
Resolves a dotted receiver expression (`user.profile`) to a fully
qualified type (`models.Profile`) via the import registry + class
definitions in `graph_nodes`. Best-effort: returns `None` when
unresolvable, never crashes.
- `refine_call_edges(workspace, db_path)` — For each CALLS edge with
a generic/unresolved `target_id`, attempts to resolve the receiver
type and updates `target_id` to the resolved node. Stores
`{"resolved_type": "...", "resolution_method": "import_registry"}`
in the edge's `extra_json` on success, or
`{"resolution_attempted": true, "failure_reason": "..."}` on failure.
Returns stats: `{edges_total, edges_refined, edges_unresolved}`.

- **`resolve-types` command + `codelens_resolve_types` MCP tool** —
Manually triggers type resolution without a full re-scan. Useful for
agents who want to refresh type resolution after adding new imports.
Output: `{status, edges_total, edges_refined, edges_unresolved,
import_registry_size}`.

- **IMPORTS edges in graph model** — The graph now carries two edge
types: `CALLS` (from #8) and `IMPORTS` (from #13). Future
`query_graph` work (#9, Phase 3) can traverse both.

### Changed (type resolution)

- **`scripts/commands/scan.py`** — After `populate_graph_tables()` (from
#8), calls `refine_call_edges(workspace, db_path)`. Scan output now
includes a `type_resolution` field: `{edges_refined, edges_unresolved}`.
- **`scripts/graph_model.py`** — `graph_stats()` now reports IMPORTS
edges in the `edge_types` breakdown alongside CALLS.

### Non-Breaking (type resolution)

- Type resolution is best-effort: unresolvable edges are left unchanged
with a `resolution_attempted` flag. No CALLS edge is ever deleted.
- The `import_registry` table is additive — no existing table modified.
- On the `clean_app` fixture: 11/97 CALLS edges refined, 55 unresolved
(the remaining 31 are self-referential or std-lib calls that don't
need refinement). On the synthetic `type_resolution` fixture
(`tests/fixtures/type_resolution/`), `user.profile.update()` correctly
refines to `Profile.update` even when a `Cache.update` competitor
exists.

### Changed

- **`scripts/persistent_registry.py`** — Calls `init_graph_schema(conn)`
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ python3 scripts/codelens.py query "myFunction" --lite
| `diff [workspace]` | Compare registry snapshots |
| `circular [workspace]` | Detect circular dependencies |
| `graph-schema [workspace]` | Cheap graph-shape introspection: node/edge counts, type distribution, indexes (issue #17) |
| `resolve-types [workspace]` | Manually trigger hybrid type resolution (import-aware CALLS edge refinement, issue #13) |
| `handbook [workspace]` | Generate project handbook for AI agents |
| `dashboard [workspace]` | Generate HTML visualization dashboard |
| `history [workspace]` | Show historical trend data and charts |
Expand Down
14 changes: 7 additions & 7 deletions SKILL-QUICK.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co
### Navigation (11)
`architecture [--lite] [--no-cache]` · `summary [--focus security|quality|architecture|all] [--detail minimal|standard|full]` · `context "name"` · `trace "name" [--direction up|down|both] [--limit N] [--offset N]` · `search "pattern" [--limit N] [--offset N]` · `symbols "name" [--fuzzy] [--limit N] [--offset N]` · `outline [--file path] [--limit N] [--offset N]` · `dependents "file"` · `list [--filter ...] [--limit N] [--offset N]` · `ask "question"` · `diff [--git-aware]`

### Architecture (9)
`entrypoints` · `api-map` · `state-map` · `detect` · `handbook` · `diff` · `dashboard` · `history` · `graph-schema`
### Architecture (10)
`entrypoints` · `api-map` · `state-map` · `detect` · `handbook` · `diff [--git-aware]` · `dashboard` · `history` · `graph-schema` · `resolve-types`

### Security (5)
`secrets [--severity ...]` · `taint` (AST-based) · `dataflow [--source ...] [--sink ...]` (cross-file) · `vuln-scan` (OSV.dev + native audit) · `env-check [--var NAME]`
Expand All @@ -143,19 +143,19 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co
### Tooling (1)
`plugin <install|list|search|update|info|validate>`

**Total: 58 commands** (56 original + `graph-schema` from #17 + `architecture` from #19; verified via `commands/__init__.py` auto-registration)
**Total: 60 commands** (56 original + `graph-schema` #17 + `architecture` #19 + `resolve-types` #13 + `git-status` #14; verified via `commands/__init__.py` auto-registration)

## MCP Server (56 Tools)
## MCP Server (58 Tools)

Start the MCP server for AI agent integration:

```bash
python3 scripts/codelens.py serve
```

Exposes 56 tools as `codelens_<command>` (e.g., `codelens_query`, `codelens_taint`, `codelens_graph_schema`, `codelens_architecture`):
- 51 statically-defined tools (full JSON schemas in `mcp_server.py`) including `codelens_graph_schema` (#17) and `codelens_architecture` (#19)
- 5 dynamically-discovered tools (`benchmark`, `dashboard`, `history`, `lsp-status`, `migrate`)
Exposes 58 tools as `codelens_<command>` (e.g., `codelens_query`, `codelens_taint`, `codelens_graph_schema`, `codelens_architecture`, `codelens_resolve_types`, `codelens_git_status`):
- 51 statically-defined tools (full JSON schemas in `mcp_server.py`) including `codelens_graph_schema` (#17), `codelens_architecture` (#19), `codelens_resolve_types` (#13), and `codelens_git_status` (#14)
- 7 dynamically-discovered tools (`benchmark`, `dashboard`, `history`, `lsp-status`, `migrate`, `diff`, `resolve-types`)
- Every tool accepts a `format` parameter (`json`/`markdown`/`ai`/`sarif`/`compact`). Use `format: "compact"` for token-efficient responses (~50% smaller than `json`).
- `watch` and `serve` itself are excluded (long-running)

Expand Down
94 changes: 94 additions & 0 deletions scripts/commands/resolve_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""resolve-types command — manually trigger hybrid type resolution (issue #13).

Runs the hybrid type resolution pass without a full re-scan. Useful for AI
agents who want to refresh type resolution after adding new imports or
modifying class definitions, without paying the cost of a full scan.

Example output::

{
"status": "ok",
"workspace": "/path/to/proj",
"edges_total": 97,
"edges_refined": 11,
"edges_unresolved": 55,
"import_registry_size": 37
}
"""

import os
from typing import Any, Dict, Optional

from commands import register_command


def add_args(parser):
"""Add resolve-types arguments to the parser."""
parser.add_argument(
"workspace",
nargs="?",
default=None,
help="Path to workspace root (auto-detected if omitted)",
)
parser.add_argument(
"--db-path",
default=None,
help="Custom path for SQLite database file",
)


def _default_db_path(workspace: str) -> str:
"""Return the default SQLite db path for a workspace."""
return os.path.join(workspace, ".codelens", "codelens.db")


def execute(args, workspace):
"""Execute the resolve-types command.

Runs ``hybrid_type_resolver.refine_call_edges`` on the workspace and
returns a stats dict. If the database doesn't exist (pre-scan), the
command auto-runs a full scan first so the type resolver has graph
tables to work with.
"""
db_path = getattr(args, "db_path", None) or _default_db_path(workspace)

if not os.path.exists(db_path):
# Auto-scan so the type resolver has graph tables to read.
try:
from commands.scan import cmd_scan
cmd_scan(workspace, incremental=False)
except Exception: # noqa: BLE001 — fail-soft
return {
"status": "error",
"error": "auto-scan failed; run 'scan' manually",
"workspace": workspace,
}

from hybrid_type_resolver import refine_call_edges, import_registry_size

try:
stats = refine_call_edges(workspace, db_path)
except Exception as exc: # noqa: BLE001 — best-effort, never crash

Check warning on line 71 in scripts/commands/resolve_types.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Fix the syntax of this issue suppression comment.

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ8NCnaLSaDKeXYVU2VG&open=AZ8NCnaLSaDKeXYVU2VG&pullRequest=29
return {
"status": "error",
"error": str(exc),
"workspace": workspace,
}

return {
"status": "ok",
"workspace": workspace,
"edges_total": stats["edges_total"],
"edges_refined": stats["edges_refined"],
"edges_unresolved": stats["edges_unresolved"],
"import_registry_size": import_registry_size(db_path),
}


register_command(
"resolve-types",
"Run hybrid type resolution: refine CALLS edges with import-aware "
"receiver types. Auto-scans if graph tables are empty.",
add_args,
execute,
)
23 changes: 23 additions & 0 deletions scripts/commands/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,25 @@ def cmd_scan(workspace: str, incremental: bool = False, plugins: Optional[list]
except Exception:
logger.warning("Graph table population failed", exc_info=True)

# ─── Hybrid Type Resolution (issue #13) ──────────────────────
# Post-pass that enriches CALLS edges with receiver type info via
# an import-aware resolver. Also writes IMPORTS edges to graph_edges
# and populates the import_registry table. Additive — only refines
# existing edges in place; never removes or replaces them. Failures
# here MUST NOT break the scan (type resolution is an optimization
# layer; unresolved edges fall back to name-based resolution).
type_resolution = {"edges_refined": 0, "edges_unresolved": 0}
try:
from hybrid_type_resolver import refine_call_edges
from graph_model import _default_db_path as _tr_db_path
tr_stats = refine_call_edges(workspace, _tr_db_path(workspace))
type_resolution = {
"edges_refined": tr_stats.get("edges_refined", 0),
"edges_unresolved": tr_stats.get("edges_unresolved", 0),
}
except Exception:
logger.warning("Hybrid type resolution failed", exc_info=True)

# ─── Git-aware scan bookmark (issue #14) ─────────────────────
# After a successful scan, record the current HEAD SHA + branch so the
# next `scan --incremental` can diff against this bookmark instead of
Expand Down Expand Up @@ -1042,6 +1061,10 @@ def cmd_scan(workspace: str, incremental: bool = False, plugins: Optional[list]
"nodes": graph_population.get("nodes", 0),
"edges": graph_population.get("edges", 0),
},
"type_resolution": {
"edges_refined": type_resolution.get("edges_refined", 0),
"edges_unresolved": type_resolution.get("edges_unresolved", 0),
},
"git": {
"last_indexed_sha": git_bookmark.get("sha"),
"last_indexed_branch": git_bookmark.get("branch"),
Expand Down
9 changes: 7 additions & 2 deletions scripts/graph_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,14 @@ def populate_graph_tables(workspace: str, db_path: Optional[str] = None) -> Dict
# init_graph_schema already logged; continue anyway
pass

# Wipe existing rows so re-scans don't accumulate duplicates.
# Wipe existing CALLS rows so re-scans don't accumulate duplicates.
# Only CALLS edges are managed by populate_graph_tables (they come
# from the flat backend registry). Other edge types (IMPORTS, etc.)
# are managed by their own builders and must survive re-population.
try:
conn.execute("DELETE FROM {}".format(GRAPH_EDGES_TABLE))
conn.execute(
"DELETE FROM {} WHERE edge_type = 'CALLS'".format(GRAPH_EDGES_TABLE)
)
conn.execute("DELETE FROM {}".format(GRAPH_NODES_TABLE))
except sqlite3.Error as e:
logger.warning(f"populate_graph_tables: clear error: {e}")
Expand Down
Loading
Loading