Skip to content

Commit 2f7a414

Browse files
worker-2-aworker-3
andcommitted
feat(types): hybrid type resolution — closes #13
Phase 1 Step 3 (final Phase 1 issue): import-aware CALLS edge refinement. Worker (full-stack-developer) implemented core feature + tests before session timeout. BOS completed docs + 1-line regression fix + commit/PR. Core feature (worker): - scripts/hybrid_type_resolver.py (1383 lines): build_import_registry, resolve_receiver_type, refine_call_edges. Parses Python from/import + TS/JS import statements, stores in import_registry SQLite table, writes IMPORTS edges to graph_edges, refines CALLS edges with resolved target_id + extra_json metadata. - scripts/commands/resolve_types.py: new CLI command (auto-registered). - scripts/commands/scan.py: calls refine_call_edges after graph population. Scan output adds type_resolution field. - scripts/graph_model.py: graph_stats reports IMPORTS edges. - tests/test_hybrid_type_resolver.py: 25 tests, all pass. Includes synthetic fixture (tests/fixtures/type_resolution/) with Cache.update vs Profile.update disambiguation case. - tests/test_graph_model.py + test_compact_format.py: updated assertions to account for IMPORTS edges now present in graph. BOS fixes: - scripts/mcp_server.py: removed manual format field from architecture tool definition (was preventing _inject_format_enum from adding compact enum, causing test_all_tools_have_format_enum regression). - README.md: added resolve-types to Architecture command table. - SKILL-QUICK.md: added resolve-types to Architecture section (9->10), updated total commands 58->60 (added resolve-types + git-status), updated MCP tools 56->58 (51 static + 7 dynamic, added codelens_resolve_types + codelens_git_status). - CHANGELOG.md: added Hybrid Type Resolution (issue #13) section under [8.2.0] with full API docs + refinement stats. Verified: - 25/25 new tests pass (test_hybrid_type_resolver.py) - Full suite: 4 pre-existing failures only (test_hybrid_engine.py confidence fields, present on main before any Phase 1 work) - Functional: scan on clean_app = type_resolution {edges_refined: 11, edges_unresolved: 55}. resolve-types standalone returns same stats. - IMPORTS edges: 37 in graph_edges (from 37 import statements). - import_registry table: 37 rows. - Synthetic fixture: user.profile.update() correctly refines to Profile.update even with Cache.update competitor. Co-authored-by: worker-3 <worker@codeassistant.local>
1 parent d1a4aa9 commit 2f7a414

14 files changed

Lines changed: 2293 additions & 24 deletions

CHANGELOG.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,69 @@ when git is unavailable or the workspace is not a git repo.
251251
scan). This is a pre-existing gap tracked in #25 and is NOT made
252252
worse by this change.
253253

254+
### Hybrid Type Resolution (issue #13)
255+
256+
Adds a post-AST-pass type resolution layer that uses the per-file import
257+
registry to refine CALLS edges. Previously `user.profile.update()` was
258+
recorded as a call to `update` with no target type — the call graph had
259+
holes wherever methods were called on imported objects. Now the receiver
260+
type is resolved via the import registry, and the CALLS edge's
261+
`target_id` is refined to the correct target node (e.g. `Profile.update`
262+
in `models.py` instead of an arbitrary `update` match).
263+
264+
### Added (type resolution)
265+
266+
- **`scripts/hybrid_type_resolver.py`** — New module with:
267+
- `build_import_registry(workspace, db_path)` — Scans Python
268+
`from X import Y` / `import X.Y as Z` and TS/JS
269+
`import {Y} from 'X'` / `import * as X from 'Y'` statements. Stores
270+
results in a new `import_registry` SQLite table
271+
`(file, local_name, module_path, symbol_name, line)`. Also writes
272+
IMPORTS edges to `graph_edges` (edge_type='IMPORTS') so the graph
273+
model now carries import relationships alongside CALLS.
274+
- `resolve_receiver_type(file_path, receiver_expr, import_registry)`
275+
Resolves a dotted receiver expression (`user.profile`) to a fully
276+
qualified type (`models.Profile`) via the import registry + class
277+
definitions in `graph_nodes`. Best-effort: returns `None` when
278+
unresolvable, never crashes.
279+
- `refine_call_edges(workspace, db_path)` — For each CALLS edge with
280+
a generic/unresolved `target_id`, attempts to resolve the receiver
281+
type and updates `target_id` to the resolved node. Stores
282+
`{"resolved_type": "...", "resolution_method": "import_registry"}`
283+
in the edge's `extra_json` on success, or
284+
`{"resolution_attempted": true, "failure_reason": "..."}` on failure.
285+
Returns stats: `{edges_total, edges_refined, edges_unresolved}`.
286+
287+
- **`resolve-types` command + `codelens_resolve_types` MCP tool**
288+
Manually triggers type resolution without a full re-scan. Useful for
289+
agents who want to refresh type resolution after adding new imports.
290+
Output: `{status, edges_total, edges_refined, edges_unresolved,
291+
import_registry_size}`.
292+
293+
- **IMPORTS edges in graph model** — The graph now carries two edge
294+
types: `CALLS` (from #8) and `IMPORTS` (from #13). Future
295+
`query_graph` work (#9, Phase 3) can traverse both.
296+
297+
### Changed (type resolution)
298+
299+
- **`scripts/commands/scan.py`** — After `populate_graph_tables()` (from
300+
#8), calls `refine_call_edges(workspace, db_path)`. Scan output now
301+
includes a `type_resolution` field: `{edges_refined, edges_unresolved}`.
302+
- **`scripts/graph_model.py`**`graph_stats()` now reports IMPORTS
303+
edges in the `edge_types` breakdown alongside CALLS.
304+
305+
### Non-Breaking (type resolution)
306+
307+
- Type resolution is best-effort: unresolvable edges are left unchanged
308+
with a `resolution_attempted` flag. No CALLS edge is ever deleted.
309+
- The `import_registry` table is additive — no existing table modified.
310+
- On the `clean_app` fixture: 11/97 CALLS edges refined, 55 unresolved
311+
(the remaining 31 are self-referential or std-lib calls that don't
312+
need refinement). On the synthetic `type_resolution` fixture
313+
(`tests/fixtures/type_resolution/`), `user.profile.update()` correctly
314+
refines to `Profile.update` even when a `Cache.update` competitor
315+
exists.
316+
254317
### Changed
255318

256319
- **`scripts/persistent_registry.py`** — Calls `init_graph_schema(conn)`

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ python3 scripts/codelens.py query "myFunction" --lite
142142
| `diff [workspace]` | Compare registry snapshots |
143143
| `circular [workspace]` | Detect circular dependencies |
144144
| `graph-schema [workspace]` | Cheap graph-shape introspection: node/edge counts, type distribution, indexes (issue #17) |
145+
| `resolve-types [workspace]` | Manually trigger hybrid type resolution (import-aware CALLS edge refinement, issue #13) |
145146
| `handbook [workspace]` | Generate project handbook for AI agents |
146147
| `dashboard [workspace]` | Generate HTML visualization dashboard |
147148
| `history [workspace]` | Show historical trend data and charts |

SKILL-QUICK.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@ $CLI list --limit 5 --offset 10 --format compact # → paginated + co
122122
### Navigation (11)
123123
`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]`
124124

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

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

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

148-
## MCP Server (56 Tools)
148+
## MCP Server (58 Tools)
149149

150150
Start the MCP server for AI agent integration:
151151

152152
```bash
153153
python3 scripts/codelens.py serve
154154
```
155155

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

scripts/commands/resolve_types.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""resolve-types command — manually trigger hybrid type resolution (issue #13).
2+
3+
Runs the hybrid type resolution pass without a full re-scan. Useful for AI
4+
agents who want to refresh type resolution after adding new imports or
5+
modifying class definitions, without paying the cost of a full scan.
6+
7+
Example output::
8+
9+
{
10+
"status": "ok",
11+
"workspace": "/path/to/proj",
12+
"edges_total": 97,
13+
"edges_refined": 11,
14+
"edges_unresolved": 55,
15+
"import_registry_size": 37
16+
}
17+
"""
18+
19+
import os
20+
from typing import Any, Dict, Optional
21+
22+
from commands import register_command
23+
24+
25+
def add_args(parser):
26+
"""Add resolve-types arguments to the parser."""
27+
parser.add_argument(
28+
"workspace",
29+
nargs="?",
30+
default=None,
31+
help="Path to workspace root (auto-detected if omitted)",
32+
)
33+
parser.add_argument(
34+
"--db-path",
35+
default=None,
36+
help="Custom path for SQLite database file",
37+
)
38+
39+
40+
def _default_db_path(workspace: str) -> str:
41+
"""Return the default SQLite db path for a workspace."""
42+
return os.path.join(workspace, ".codelens", "codelens.db")
43+
44+
45+
def execute(args, workspace):
46+
"""Execute the resolve-types command.
47+
48+
Runs ``hybrid_type_resolver.refine_call_edges`` on the workspace and
49+
returns a stats dict. If the database doesn't exist (pre-scan), the
50+
command auto-runs a full scan first so the type resolver has graph
51+
tables to work with.
52+
"""
53+
db_path = getattr(args, "db_path", None) or _default_db_path(workspace)
54+
55+
if not os.path.exists(db_path):
56+
# Auto-scan so the type resolver has graph tables to read.
57+
try:
58+
from commands.scan import cmd_scan
59+
cmd_scan(workspace, incremental=False)
60+
except Exception: # noqa: BLE001 — fail-soft
61+
return {
62+
"status": "error",
63+
"error": "auto-scan failed; run 'scan' manually",
64+
"workspace": workspace,
65+
}
66+
67+
from hybrid_type_resolver import refine_call_edges, import_registry_size
68+
69+
try:
70+
stats = refine_call_edges(workspace, db_path)
71+
except Exception as exc: # noqa: BLE001 — best-effort, never crash
72+
return {
73+
"status": "error",
74+
"error": str(exc),
75+
"workspace": workspace,
76+
}
77+
78+
return {
79+
"status": "ok",
80+
"workspace": workspace,
81+
"edges_total": stats["edges_total"],
82+
"edges_refined": stats["edges_refined"],
83+
"edges_unresolved": stats["edges_unresolved"],
84+
"import_registry_size": import_registry_size(db_path),
85+
}
86+
87+
88+
register_command(
89+
"resolve-types",
90+
"Run hybrid type resolution: refine CALLS edges with import-aware "
91+
"receiver types. Auto-scans if graph tables are empty.",
92+
add_args,
93+
execute,
94+
)

scripts/commands/scan.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,25 @@ def cmd_scan(workspace: str, incremental: bool = False, plugins: Optional[list]
927927
except Exception:
928928
logger.warning("Graph table population failed", exc_info=True)
929929

930+
# ─── Hybrid Type Resolution (issue #13) ──────────────────────
931+
# Post-pass that enriches CALLS edges with receiver type info via
932+
# an import-aware resolver. Also writes IMPORTS edges to graph_edges
933+
# and populates the import_registry table. Additive — only refines
934+
# existing edges in place; never removes or replaces them. Failures
935+
# here MUST NOT break the scan (type resolution is an optimization
936+
# layer; unresolved edges fall back to name-based resolution).
937+
type_resolution = {"edges_refined": 0, "edges_unresolved": 0}
938+
try:
939+
from hybrid_type_resolver import refine_call_edges
940+
from graph_model import _default_db_path as _tr_db_path
941+
tr_stats = refine_call_edges(workspace, _tr_db_path(workspace))
942+
type_resolution = {
943+
"edges_refined": tr_stats.get("edges_refined", 0),
944+
"edges_unresolved": tr_stats.get("edges_unresolved", 0),
945+
}
946+
except Exception:
947+
logger.warning("Hybrid type resolution failed", exc_info=True)
948+
930949
# ─── Git-aware scan bookmark (issue #14) ─────────────────────
931950
# After a successful scan, record the current HEAD SHA + branch so the
932951
# next `scan --incremental` can diff against this bookmark instead of
@@ -1042,6 +1061,10 @@ def cmd_scan(workspace: str, incremental: bool = False, plugins: Optional[list]
10421061
"nodes": graph_population.get("nodes", 0),
10431062
"edges": graph_population.get("edges", 0),
10441063
},
1064+
"type_resolution": {
1065+
"edges_refined": type_resolution.get("edges_refined", 0),
1066+
"edges_unresolved": type_resolution.get("edges_unresolved", 0),
1067+
},
10451068
"git": {
10461069
"last_indexed_sha": git_bookmark.get("sha"),
10471070
"last_indexed_branch": git_bookmark.get("branch"),

scripts/graph_model.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,14 @@ def populate_graph_tables(workspace: str, db_path: Optional[str] = None) -> Dict
268268
# init_graph_schema already logged; continue anyway
269269
pass
270270

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

0 commit comments

Comments
 (0)