seam-code 0.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.
- seam/__init__.py +3 -0
- seam/_data/schema.sql +225 -0
- seam/_web/assets/index-BL_tqprR.js +216 -0
- seam/_web/assets/index-GTKUhVyD.css +1 -0
- seam/_web/index.html +13 -0
- seam/analysis/__init__.py +14 -0
- seam/analysis/affected.py +254 -0
- seam/analysis/builtins.py +966 -0
- seam/analysis/byte_budget.py +217 -0
- seam/analysis/changes.py +709 -0
- seam/analysis/cluster_naming.py +260 -0
- seam/analysis/clustering.py +216 -0
- seam/analysis/confidence.py +699 -0
- seam/analysis/embeddings.py +195 -0
- seam/analysis/flows.py +708 -0
- seam/analysis/impact.py +444 -0
- seam/analysis/imports.py +994 -0
- seam/analysis/imports_ext.py +780 -0
- seam/analysis/imports_resolve.py +176 -0
- seam/analysis/processes.py +453 -0
- seam/analysis/relevance.py +155 -0
- seam/analysis/rwr.py +129 -0
- seam/analysis/staleness.py +328 -0
- seam/analysis/steer.py +282 -0
- seam/analysis/synthesis.py +253 -0
- seam/analysis/synthesis_channels.py +433 -0
- seam/analysis/testpaths.py +103 -0
- seam/analysis/traversal.py +470 -0
- seam/cli/__init__.py +0 -0
- seam/cli/install.py +232 -0
- seam/cli/main.py +2602 -0
- seam/cli/output.py +137 -0
- seam/cli/read.py +244 -0
- seam/cli/serve.py +145 -0
- seam/config.py +551 -0
- seam/indexer/__init__.py +0 -0
- seam/indexer/cluster_index.py +425 -0
- seam/indexer/db.py +496 -0
- seam/indexer/embedding_index.py +183 -0
- seam/indexer/field_access.py +536 -0
- seam/indexer/field_access_c_cpp.py +643 -0
- seam/indexer/field_access_ext.py +708 -0
- seam/indexer/field_access_ext2.py +408 -0
- seam/indexer/field_access_go_rust.py +737 -0
- seam/indexer/field_access_php_swift.py +888 -0
- seam/indexer/field_access_ts.py +626 -0
- seam/indexer/graph.py +321 -0
- seam/indexer/graph_c.py +562 -0
- seam/indexer/graph_c_cpp.py +39 -0
- seam/indexer/graph_common.py +644 -0
- seam/indexer/graph_cpp.py +615 -0
- seam/indexer/graph_csharp.py +651 -0
- seam/indexer/graph_go.py +723 -0
- seam/indexer/graph_go_rust.py +39 -0
- seam/indexer/graph_java.py +689 -0
- seam/indexer/graph_java_csharp.py +38 -0
- seam/indexer/graph_php.py +914 -0
- seam/indexer/graph_python.py +628 -0
- seam/indexer/graph_ruby.py +748 -0
- seam/indexer/graph_rust.py +653 -0
- seam/indexer/graph_scope_infer.py +902 -0
- seam/indexer/graph_scope_infer_ext.py +723 -0
- seam/indexer/graph_scope_infer_ext2.py +992 -0
- seam/indexer/graph_swift.py +1014 -0
- seam/indexer/graph_swift_infer.py +515 -0
- seam/indexer/graph_typescript.py +663 -0
- seam/indexer/migrations.py +816 -0
- seam/indexer/parser.py +204 -0
- seam/indexer/pipeline.py +197 -0
- seam/indexer/signatures.py +634 -0
- seam/indexer/signatures_ext.py +780 -0
- seam/indexer/sync.py +287 -0
- seam/indexer/synthesis_index.py +291 -0
- seam/indexer/tokenize.py +79 -0
- seam/installer/__init__.py +67 -0
- seam/installer/claude.py +97 -0
- seam/installer/codex.py +94 -0
- seam/installer/core.py +127 -0
- seam/installer/cursor.py +61 -0
- seam/installer/guide.py +110 -0
- seam/installer/jsonfile.py +85 -0
- seam/installer/markdownfile.py +146 -0
- seam/installer/tomlfile.py +72 -0
- seam/query/__init__.py +0 -0
- seam/query/clusters.py +206 -0
- seam/query/comments.py +217 -0
- seam/query/context.py +293 -0
- seam/query/engine.py +940 -0
- seam/query/fts.py +328 -0
- seam/query/names.py +470 -0
- seam/query/pack.py +433 -0
- seam/query/semantic.py +339 -0
- seam/query/structure.py +727 -0
- seam/server/__init__.py +0 -0
- seam/server/graph_api.py +437 -0
- seam/server/handler_common.py +323 -0
- seam/server/impact_handler.py +615 -0
- seam/server/mcp.py +556 -0
- seam/server/tools.py +697 -0
- seam/server/trace_handler.py +184 -0
- seam/server/web.py +922 -0
- seam/watcher/__init__.py +0 -0
- seam/watcher/__main__.py +56 -0
- seam/watcher/daemon.py +237 -0
- seam_code-0.3.0.dist-info/METADATA +318 -0
- seam_code-0.3.0.dist-info/RECORD +109 -0
- seam_code-0.3.0.dist-info/WHEEL +4 -0
- seam_code-0.3.0.dist-info/entry_points.txt +2 -0
- seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
"""Whole-index confidence resolver — single source of truth for the
|
|
2
|
+
EXTRACTED / AMBIGUOUS / INFERRED rule.
|
|
3
|
+
|
|
4
|
+
Phase 5 additions:
|
|
5
|
+
- Resolution TypedDict: confidence + resolved_by + best_candidate.
|
|
6
|
+
- resolve_edge(): full resolver with provenance, builtin filtering, import
|
|
7
|
+
promotion (step A), and proximity tie-break (step D).
|
|
8
|
+
- load_import_mappings(): load a file's import bindings from the DB.
|
|
9
|
+
- resolve() kept as a backward-compat thin shim.
|
|
10
|
+
|
|
11
|
+
Resolution rule (scope: whole index, evaluated at read time):
|
|
12
|
+
1. Same-file import binds target to exactly one indexed file → EXTRACTED, 'import'.
|
|
13
|
+
2. name count == 1 → EXTRACTED, 'name-unique'.
|
|
14
|
+
name count > 1 → AMBIGUOUS, 'name-collision' (+ proximity best_candidate).
|
|
15
|
+
3. count == 0 AND is_builtin(name, lang) → INFERRED, 'builtin'.
|
|
16
|
+
count == 0 AND not builtin → INFERRED, 'unresolved'.
|
|
17
|
+
|
|
18
|
+
Design rationale:
|
|
19
|
+
Confidence is a property of *global* state (the full index), not a per-file
|
|
20
|
+
property. Resolving at read time means it is always fresh after any
|
|
21
|
+
incremental watcher re-index — no write-amplification, no staleness.
|
|
22
|
+
The stored edges.confidence column is a same-file lower-bound hint kept for
|
|
23
|
+
debugging; read-time resolution here is authoritative and overrides it.
|
|
24
|
+
|
|
25
|
+
Import rules (no circular deps):
|
|
26
|
+
This module imports ONLY stdlib + seam.analysis.builtins + seam.analysis.imports.
|
|
27
|
+
It must NOT import traversal.py, flows.py, or any other seam analysis module.
|
|
28
|
+
traversal.py and flows.py import their confidence constants FROM here.
|
|
29
|
+
builtins.py and imports.py are LEAVES — they import only stdlib.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
import sqlite3
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import TypedDict
|
|
37
|
+
|
|
38
|
+
import seam.config as config
|
|
39
|
+
from seam.analysis.builtins import is_builtin
|
|
40
|
+
from seam.analysis.imports import (
|
|
41
|
+
ImportMapping,
|
|
42
|
+
compute_path_proximity,
|
|
43
|
+
resolve_import_source,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
# ── Module-level "already warned" guard ──────────────────────────────────────
|
|
49
|
+
# Prevents the empty-import_mappings warning from firing on every hop of a hot traversal.
|
|
50
|
+
# Set to True on the first emission; never reset so it fires at most once per process.
|
|
51
|
+
_import_mappings_empty_warned: bool = False
|
|
52
|
+
|
|
53
|
+
# ── Canonical confidence constants ────────────────────────────────────────────
|
|
54
|
+
# These are the three possible values for the confidence field on edges and hops.
|
|
55
|
+
# All other seam modules (traversal, flows) import them from here.
|
|
56
|
+
|
|
57
|
+
CONFIDENCE_EXTRACTED = "EXTRACTED" # target name is unique in the full index
|
|
58
|
+
CONFIDENCE_AMBIGUOUS = "AMBIGUOUS" # target name matches >1 indexed symbol
|
|
59
|
+
CONFIDENCE_INFERRED = "INFERRED" # target not indexed (external, stdlib, dynamic)
|
|
60
|
+
|
|
61
|
+
# ── resolved_by vocabulary (Phase 5) ──────────────────────────────────────────
|
|
62
|
+
# Stable string enum — surfaced verbatim in MCP/CLI output.
|
|
63
|
+
# null/None for pre-v6 / unresolved-context rows (same null-contract as Phase 4 fields).
|
|
64
|
+
RESOLVED_BY_IMPORT = "import" # promoted via a resolved same-file import
|
|
65
|
+
RESOLVED_BY_NAME_UNIQUE = "name-unique" # name appears exactly once in the index
|
|
66
|
+
RESOLVED_BY_COLLISION = "name-collision" # name appears >1 times (homonym)
|
|
67
|
+
RESOLVED_BY_BUILTIN = "builtin" # name is a known language builtin (count==0)
|
|
68
|
+
RESOLVED_BY_UNRESOLVED = "unresolved" # count==0, not a builtin
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Resolution(TypedDict):
|
|
72
|
+
"""Result of resolve_edge(): confidence tier + provenance + optional tie-break.
|
|
73
|
+
|
|
74
|
+
Fields:
|
|
75
|
+
confidence: EXTRACTED | AMBIGUOUS | INFERRED
|
|
76
|
+
resolved_by: how the tier was decided (see RESOLVED_BY_* constants above)
|
|
77
|
+
None for pre-v6 / unknown-context rows (null-contract)
|
|
78
|
+
best_candidate: for AMBIGUOUS edges, the most file-path-proximate declaring file
|
|
79
|
+
path (None when not applicable or no proximity data available)
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
confidence: str
|
|
83
|
+
resolved_by: str | None
|
|
84
|
+
best_candidate: str | None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ── DB helper ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_name_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
|
91
|
+
"""Load a name → count map from the symbols table in a single GROUP BY query.
|
|
92
|
+
|
|
93
|
+
This is the only DB call this module makes. Called once per query in
|
|
94
|
+
traversal.walk / flows.trace / flows.callers / flows.callees so that
|
|
95
|
+
confidence is resolved against the full index without per-edge round-trips.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
conn: Open SQLite connection (read-only semantics; no writes).
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
dict mapping every symbol name to its occurrence count across all files.
|
|
102
|
+
An empty dict when the symbols table has no rows.
|
|
103
|
+
"""
|
|
104
|
+
rows = conn.execute("SELECT name, COUNT(*) AS cnt FROM symbols GROUP BY name").fetchall()
|
|
105
|
+
# Positional access works under any row_factory (and db.connect() sets sqlite3.Row).
|
|
106
|
+
result: dict[str, int] = {row[0]: row[1] for row in rows}
|
|
107
|
+
if not result:
|
|
108
|
+
# Empty map → EVERY edge resolves to INFERRED (the exact silent degradation
|
|
109
|
+
# issue #9 fixed). Surface it loudly: an empty symbols table almost always
|
|
110
|
+
# means the index was never built or is mid-rebuild, not that the code is
|
|
111
|
+
# genuinely all-external. Without this, the regression returns invisibly.
|
|
112
|
+
logger.warning(
|
|
113
|
+
"load_name_counts: symbols table is empty — all edge confidence will "
|
|
114
|
+
"resolve to INFERRED. Run 'seam init' to (re)build the index."
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
logger.debug("load_name_counts: %d distinct symbol names loaded", len(result))
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_import_mappings(
|
|
122
|
+
conn: sqlite3.Connection,
|
|
123
|
+
file_path: str,
|
|
124
|
+
) -> list[ImportMapping]:
|
|
125
|
+
"""Load import mappings for a file from the import_mappings table.
|
|
126
|
+
|
|
127
|
+
Single DB query, mirrors load_name_counts() design pattern.
|
|
128
|
+
Returns [] if the table doesn't exist (pre-v6 DB) or the file is unknown.
|
|
129
|
+
Never raises.
|
|
130
|
+
|
|
131
|
+
Emits a once-per-process warning when the import_mappings table is empty while
|
|
132
|
+
symbols exist — almost always a stale pre-v6 index that needs 'seam init'.
|
|
133
|
+
Mirroring load_name_counts()'s empty-index warning pattern.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
conn: Open SQLite connection (read-only semantics).
|
|
137
|
+
file_path: Absolute path of the referencing file (as stored in files.path).
|
|
138
|
+
"""
|
|
139
|
+
global _import_mappings_empty_warned # noqa: PLW0603 — module-level once-warning guard
|
|
140
|
+
try:
|
|
141
|
+
rows = conn.execute(
|
|
142
|
+
"""
|
|
143
|
+
SELECT im.local_name, im.exported_name, im.source_module,
|
|
144
|
+
im.is_default, im.is_namespace, im.is_wildcard, im.line
|
|
145
|
+
FROM import_mappings im
|
|
146
|
+
JOIN files f ON f.id = im.file_id
|
|
147
|
+
WHERE f.path = ?
|
|
148
|
+
""",
|
|
149
|
+
(file_path,),
|
|
150
|
+
).fetchall()
|
|
151
|
+
|
|
152
|
+
# Guard: only warn when the TABLE is empty, not just this file (which may import nothing).
|
|
153
|
+
# The extra two SELECTs are cheap because they run at most once per process.
|
|
154
|
+
if not rows and not _import_mappings_empty_warned:
|
|
155
|
+
try:
|
|
156
|
+
any_mapping = conn.execute("SELECT 1 FROM import_mappings LIMIT 1").fetchone()
|
|
157
|
+
any_symbol = conn.execute("SELECT 1 FROM symbols LIMIT 1").fetchone()
|
|
158
|
+
if any_mapping is None and any_symbol is not None:
|
|
159
|
+
_import_mappings_empty_warned = True
|
|
160
|
+
logger.warning(
|
|
161
|
+
"load_import_mappings: import_mappings table is empty but symbols exist — "
|
|
162
|
+
"run 'seam init' to enable import promotion (Phase 5 resolution)."
|
|
163
|
+
)
|
|
164
|
+
except Exception: # noqa: BLE001
|
|
165
|
+
pass # pre-v6 DB — table absent, warning is moot
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
ImportMapping(
|
|
169
|
+
local_name=row["local_name"],
|
|
170
|
+
exported_name=row["exported_name"],
|
|
171
|
+
source_module=row["source_module"],
|
|
172
|
+
is_default=bool(row["is_default"]),
|
|
173
|
+
is_namespace=bool(row["is_namespace"]),
|
|
174
|
+
is_wildcard=bool(row["is_wildcard"]),
|
|
175
|
+
line=row["line"],
|
|
176
|
+
)
|
|
177
|
+
for row in rows
|
|
178
|
+
]
|
|
179
|
+
except Exception: # noqa: BLE001
|
|
180
|
+
# Table absent (pre-v6) or any other error → degrade gracefully
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# P4 — barrel directory-entry filenames. A directory import (`from './barrel'`)
|
|
185
|
+
# resolves to one of these inside that directory. TS/JS only — the languages
|
|
186
|
+
# where the barrel/index.* convention exists. Ordered by precedence.
|
|
187
|
+
_BARREL_INDEX_FILES = ("index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _resolve_barrel_source(
|
|
191
|
+
source_module: str,
|
|
192
|
+
referencing_file: Path,
|
|
193
|
+
repo_root: Path,
|
|
194
|
+
language: str,
|
|
195
|
+
max_import_candidates: int,
|
|
196
|
+
) -> list[str]:
|
|
197
|
+
"""Resolve an import source, adding TS/JS directory→index.* barrel probing.
|
|
198
|
+
|
|
199
|
+
`resolve_import_source` does not map a directory import (`./barrel`) to its
|
|
200
|
+
`index.ts` entry (the TS extension order has no `/index.*`). Barrels ARE
|
|
201
|
+
directory imports, so this P4-local helper falls back to probing for a
|
|
202
|
+
barrel index file inside the resolved directory when the normal resolution
|
|
203
|
+
returns nothing. Only applies to TS/JS; never raises.
|
|
204
|
+
"""
|
|
205
|
+
hits = resolve_import_source(source_module, referencing_file, repo_root, language)[
|
|
206
|
+
:max_import_candidates
|
|
207
|
+
]
|
|
208
|
+
if hits or language not in ("typescript", "javascript"):
|
|
209
|
+
return hits
|
|
210
|
+
|
|
211
|
+
# Directory-import barrel fallback: probe <dir>/index.* for relative sources.
|
|
212
|
+
if not (source_module.startswith("./") or source_module.startswith("../")):
|
|
213
|
+
return hits
|
|
214
|
+
try:
|
|
215
|
+
base = Path(os.path.normpath(referencing_file.parent / source_module))
|
|
216
|
+
except Exception: # noqa: BLE001
|
|
217
|
+
return hits
|
|
218
|
+
for index_name in _BARREL_INDEX_FILES:
|
|
219
|
+
candidate = base / index_name
|
|
220
|
+
if candidate.exists():
|
|
221
|
+
return [str(candidate)]
|
|
222
|
+
return hits
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _chase_barrel(
|
|
226
|
+
exported: str,
|
|
227
|
+
barrel_paths: list[str],
|
|
228
|
+
repo_root: Path,
|
|
229
|
+
conn: sqlite3.Connection,
|
|
230
|
+
language: str,
|
|
231
|
+
max_import_candidates: int,
|
|
232
|
+
depth: int,
|
|
233
|
+
visited: set[tuple[str, str]],
|
|
234
|
+
) -> str | None:
|
|
235
|
+
"""Follow a re-export chain through barrel files to the real declarer.
|
|
236
|
+
|
|
237
|
+
P4 — barrel re-export following. A barrel (e.g. index.ts) re-exports a name
|
|
238
|
+
from a sibling but does not declare it. This walks each barrel's OWN
|
|
239
|
+
import_mappings for `exported`, resolves the re-export source to file(s),
|
|
240
|
+
checks whether those declare the name, and recurses if they are themselves
|
|
241
|
+
barrels — up to `depth` more hops.
|
|
242
|
+
|
|
243
|
+
Returns the single declaring file path on success, or None when the chain
|
|
244
|
+
dead-ends, branches ambiguously, exceeds depth, or cycles. Never raises.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
exported: The exported name being chased (stable across hops here —
|
|
248
|
+
re-exports modeled as non-aliased named bindings).
|
|
249
|
+
barrel_paths: Candidate barrel file paths to inspect at this hop.
|
|
250
|
+
repo_root: Repo root for import-source resolution.
|
|
251
|
+
conn: DB connection (read-only).
|
|
252
|
+
language: Language of the chain (TS/JS barrels in practice).
|
|
253
|
+
max_import_candidates: Per-import candidate cap (perf bound).
|
|
254
|
+
depth: Remaining hops allowed (decremented per recursion).
|
|
255
|
+
visited: (file, name) pairs already seen — cycle + repeat-DB guard.
|
|
256
|
+
"""
|
|
257
|
+
if depth <= 0:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
for barrel_path in barrel_paths:
|
|
261
|
+
key = (barrel_path, exported)
|
|
262
|
+
if key in visited:
|
|
263
|
+
# Cycle or already-explored barrel — skip to guarantee termination
|
|
264
|
+
# and avoid repeated DB hits (the per-resolution cache requirement).
|
|
265
|
+
continue
|
|
266
|
+
visited.add(key)
|
|
267
|
+
|
|
268
|
+
# Load THIS barrel's own re-export mappings for the exported name.
|
|
269
|
+
barrel_maps = load_import_mappings(conn, barrel_path)
|
|
270
|
+
try:
|
|
271
|
+
barrel_dir = Path(barrel_path)
|
|
272
|
+
except Exception: # noqa: BLE001
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
for m in barrel_maps:
|
|
276
|
+
if m["is_wildcard"] or m["is_namespace"]:
|
|
277
|
+
continue
|
|
278
|
+
# Match on the local binding the barrel re-exports under (the name a
|
|
279
|
+
# consumer sees) — equals `exported` for plain `export { X } from`.
|
|
280
|
+
if m["local_name"] != exported:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
next_exported = m["exported_name"]
|
|
284
|
+
next_paths = _resolve_barrel_source(
|
|
285
|
+
m["source_module"], barrel_dir, repo_root, language,
|
|
286
|
+
max_import_candidates,
|
|
287
|
+
)
|
|
288
|
+
if not next_paths:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
# Does any resolved file actually DECLARE the name? → real declarer.
|
|
292
|
+
declaring = _declaring_files(conn, next_exported, next_paths)
|
|
293
|
+
if len(declaring) == 1:
|
|
294
|
+
return declaring[0]
|
|
295
|
+
if len(declaring) > 1:
|
|
296
|
+
# Branches to multiple declarers → genuinely ambiguous; stop.
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
# None declare it → those files are themselves barrels; recurse.
|
|
300
|
+
hit = _chase_barrel(
|
|
301
|
+
next_exported, next_paths, repo_root, conn, language,
|
|
302
|
+
max_import_candidates, depth - 1, visited,
|
|
303
|
+
)
|
|
304
|
+
if hit is not None:
|
|
305
|
+
return hit
|
|
306
|
+
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _declaring_files(
|
|
311
|
+
conn: sqlite3.Connection,
|
|
312
|
+
name: str,
|
|
313
|
+
candidate_paths: list[str],
|
|
314
|
+
) -> list[str]:
|
|
315
|
+
"""Return which of candidate_paths declare `name` in the index. Never raises."""
|
|
316
|
+
try:
|
|
317
|
+
rows = conn.execute(
|
|
318
|
+
"""
|
|
319
|
+
SELECT f.path FROM symbols s
|
|
320
|
+
JOIN files f ON f.id = s.file_id
|
|
321
|
+
WHERE s.name = ? AND f.path IN ({})
|
|
322
|
+
""".format(",".join("?" * len(candidate_paths))),
|
|
323
|
+
[name, *candidate_paths],
|
|
324
|
+
).fetchall()
|
|
325
|
+
except Exception: # noqa: BLE001
|
|
326
|
+
return []
|
|
327
|
+
return [row["path"] for row in rows]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _resolve_with_import_promotion(
|
|
331
|
+
target_name: str,
|
|
332
|
+
name_counts: dict[str, int],
|
|
333
|
+
import_mappings: list[ImportMapping],
|
|
334
|
+
referencing_file: Path,
|
|
335
|
+
repo_root: Path,
|
|
336
|
+
conn: sqlite3.Connection,
|
|
337
|
+
language: str,
|
|
338
|
+
max_import_candidates: int,
|
|
339
|
+
max_proximity_candidates: int,
|
|
340
|
+
) -> Resolution:
|
|
341
|
+
"""Attempt import-promotion resolution (step A).
|
|
342
|
+
|
|
343
|
+
Checks same-file import mappings for a binding of target_name to exactly
|
|
344
|
+
one indexed declaring file. If found, promotes to EXTRACTED 'import'.
|
|
345
|
+
|
|
346
|
+
P4: when the resolved source file does NOT itself declare the exported name
|
|
347
|
+
(i.e. it is a barrel that re-exports from siblings), the chain is followed
|
|
348
|
+
through the barrel's OWN import_mappings up to SEAM_BARREL_DEPTH hops to find
|
|
349
|
+
the real declarer (bounded + cycle-safe + cached per (file, name)).
|
|
350
|
+
|
|
351
|
+
Falls through to name-count resolution when:
|
|
352
|
+
- No import binding found.
|
|
353
|
+
- Import source resolves to no indexed file (third-party).
|
|
354
|
+
- Resolved file does NOT declare the exported name AND no barrel chain
|
|
355
|
+
reaches a single declarer (prevents false promotion).
|
|
356
|
+
- Import is wildcard (no specific binding — star import, story 27).
|
|
357
|
+
|
|
358
|
+
Args are pre-validated by the caller; this function does not guard them.
|
|
359
|
+
"""
|
|
360
|
+
# Scan import mappings for a binding of target_name.
|
|
361
|
+
# Wildcards and namespace imports are skipped: a wildcard has no specific exported-name
|
|
362
|
+
# binding to look up, and a namespace import binds the whole module object rather
|
|
363
|
+
# than an individual symbol, so neither can narrow the target to a declaring file.
|
|
364
|
+
for mapping in import_mappings:
|
|
365
|
+
if mapping["is_wildcard"]:
|
|
366
|
+
continue
|
|
367
|
+
if mapping["is_namespace"]:
|
|
368
|
+
continue
|
|
369
|
+
if mapping["local_name"] != target_name:
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
# This import binds target_name locally. Resolve the source module to file paths.
|
|
373
|
+
# When barrel following is enabled, a directory import (`from './barrel'`)
|
|
374
|
+
# additionally resolves to its index.* entry so the chain can be chased;
|
|
375
|
+
# SEAM_BARREL_DEPTH=0 uses the plain resolver → byte-identical to pre-P4.
|
|
376
|
+
if config.SEAM_BARREL_DEPTH > 0:
|
|
377
|
+
candidate_paths = _resolve_barrel_source(
|
|
378
|
+
mapping["source_module"],
|
|
379
|
+
referencing_file,
|
|
380
|
+
repo_root,
|
|
381
|
+
language,
|
|
382
|
+
max_import_candidates,
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
candidate_paths = resolve_import_source(
|
|
386
|
+
mapping["source_module"],
|
|
387
|
+
referencing_file,
|
|
388
|
+
repo_root,
|
|
389
|
+
language,
|
|
390
|
+
)[:max_import_candidates]
|
|
391
|
+
|
|
392
|
+
if not candidate_paths:
|
|
393
|
+
# Third-party or unresolvable source — fall through to name-count rule.
|
|
394
|
+
# Debug log so "why AMBIGUOUS not EXTRACTED" is diagnosable without reading source.
|
|
395
|
+
logger.debug(
|
|
396
|
+
"_resolve_with_import_promotion: %r — source %r unresolvable (third-party/out-of-scope)",
|
|
397
|
+
target_name,
|
|
398
|
+
mapping["source_module"],
|
|
399
|
+
)
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
# Check which candidate files actually declare the exported name in the index.
|
|
403
|
+
exported = mapping["exported_name"]
|
|
404
|
+
declaring_paths = _declaring_files(conn, exported, candidate_paths)
|
|
405
|
+
if len(declaring_paths) == 1:
|
|
406
|
+
# Exactly one indexed file declares the name → promote to EXTRACTED
|
|
407
|
+
return Resolution(
|
|
408
|
+
confidence=CONFIDENCE_EXTRACTED,
|
|
409
|
+
resolved_by=RESOLVED_BY_IMPORT,
|
|
410
|
+
best_candidate=declaring_paths[0],
|
|
411
|
+
)
|
|
412
|
+
# 0 or >1 declaring files — can't promote directly; fall through.
|
|
413
|
+
# Debug logs so "why AMBIGUOUS not EXTRACTED" is answerable without reading source.
|
|
414
|
+
if not declaring_paths:
|
|
415
|
+
# P4: the resolved source declares nothing — it may be a BARREL that
|
|
416
|
+
# re-exports the name from a sibling. Chase the re-export chain
|
|
417
|
+
# (bounded by SEAM_BARREL_DEPTH, cycle-safe, cached per (file, name))
|
|
418
|
+
# to find the real declarer. SEAM_BARREL_DEPTH=0 disables → pre-P4.
|
|
419
|
+
if config.SEAM_BARREL_DEPTH > 0:
|
|
420
|
+
hit = _chase_barrel(
|
|
421
|
+
exported=exported,
|
|
422
|
+
barrel_paths=candidate_paths,
|
|
423
|
+
repo_root=repo_root,
|
|
424
|
+
conn=conn,
|
|
425
|
+
language=language,
|
|
426
|
+
max_import_candidates=max_import_candidates,
|
|
427
|
+
depth=config.SEAM_BARREL_DEPTH,
|
|
428
|
+
visited=set(),
|
|
429
|
+
)
|
|
430
|
+
if hit is not None:
|
|
431
|
+
return Resolution(
|
|
432
|
+
confidence=CONFIDENCE_EXTRACTED,
|
|
433
|
+
resolved_by=RESOLVED_BY_IMPORT,
|
|
434
|
+
best_candidate=hit,
|
|
435
|
+
)
|
|
436
|
+
logger.debug(
|
|
437
|
+
"_resolve_with_import_promotion: %r — exported name %r not declared in resolved file(s) %s",
|
|
438
|
+
target_name,
|
|
439
|
+
exported,
|
|
440
|
+
candidate_paths,
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
logger.debug(
|
|
444
|
+
"_resolve_with_import_promotion: %r — %d declaring files found (>1, ambiguous): %s",
|
|
445
|
+
target_name,
|
|
446
|
+
len(declaring_paths),
|
|
447
|
+
declaring_paths,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# No import binding matched — fall through to name-count rule.
|
|
451
|
+
logger.debug(
|
|
452
|
+
"_resolve_with_import_promotion: %r — no import binding matched, using name-count rule",
|
|
453
|
+
target_name,
|
|
454
|
+
)
|
|
455
|
+
return _resolve_name_count(
|
|
456
|
+
target_name,
|
|
457
|
+
name_counts,
|
|
458
|
+
language=language,
|
|
459
|
+
referencing_file=referencing_file,
|
|
460
|
+
max_proximity_candidates=max_proximity_candidates,
|
|
461
|
+
conn=conn,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _resolve_name_count(
|
|
466
|
+
target_name: str,
|
|
467
|
+
name_counts: dict[str, int],
|
|
468
|
+
language: str | None = None,
|
|
469
|
+
referencing_file: Path | None = None,
|
|
470
|
+
max_proximity_candidates: int = 25,
|
|
471
|
+
conn: sqlite3.Connection | None = None,
|
|
472
|
+
candidate_files: list[str] | None = None,
|
|
473
|
+
) -> Resolution:
|
|
474
|
+
"""Name-count resolution rule with builtin filtering and proximity tie-break.
|
|
475
|
+
|
|
476
|
+
Resolution order:
|
|
477
|
+
count == 1 → EXTRACTED, 'name-unique'.
|
|
478
|
+
count > 1 → AMBIGUOUS, 'name-collision' + proximity best_candidate if possible.
|
|
479
|
+
count == 0 AND is_builtin(name, lang) → INFERRED, 'builtin'.
|
|
480
|
+
count == 0 AND not builtin → INFERRED, 'unresolved'.
|
|
481
|
+
|
|
482
|
+
The builtin check fires ONLY when count==0 — structural guarantee for story 5.
|
|
483
|
+
"""
|
|
484
|
+
count = name_counts.get(target_name, 0)
|
|
485
|
+
|
|
486
|
+
if count == 1:
|
|
487
|
+
return Resolution(
|
|
488
|
+
confidence=CONFIDENCE_EXTRACTED,
|
|
489
|
+
resolved_by=RESOLVED_BY_NAME_UNIQUE,
|
|
490
|
+
best_candidate=None,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if count > 1:
|
|
494
|
+
# AMBIGUOUS — proximity narrows the best_candidate hint but keeps the tier
|
|
495
|
+
# AMBIGUOUS because path distance alone can't guarantee the correct target.
|
|
496
|
+
best = _proximity_best_candidate(
|
|
497
|
+
target_name=target_name,
|
|
498
|
+
referencing_file=referencing_file,
|
|
499
|
+
max_candidates=max_proximity_candidates,
|
|
500
|
+
conn=conn,
|
|
501
|
+
candidate_files=candidate_files,
|
|
502
|
+
)
|
|
503
|
+
return Resolution(
|
|
504
|
+
confidence=CONFIDENCE_AMBIGUOUS,
|
|
505
|
+
resolved_by=RESOLVED_BY_COLLISION,
|
|
506
|
+
best_candidate=best,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# count == 0: check builtins ONLY here (structural guard for story 5).
|
|
510
|
+
# A user-defined name with count >= 1 can NEVER reach this branch.
|
|
511
|
+
# SEAM_BUILTIN_FILTERING="off" disables builtin tagging entirely.
|
|
512
|
+
if language and config.SEAM_BUILTIN_FILTERING == "on" and is_builtin(target_name, language):
|
|
513
|
+
return Resolution(
|
|
514
|
+
confidence=CONFIDENCE_INFERRED,
|
|
515
|
+
resolved_by=RESOLVED_BY_BUILTIN,
|
|
516
|
+
best_candidate=None,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
return Resolution(
|
|
520
|
+
confidence=CONFIDENCE_INFERRED,
|
|
521
|
+
resolved_by=RESOLVED_BY_UNRESOLVED,
|
|
522
|
+
best_candidate=None,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _proximity_best_candidate(
|
|
527
|
+
target_name: str,
|
|
528
|
+
referencing_file: Path | None,
|
|
529
|
+
max_candidates: int,
|
|
530
|
+
conn: sqlite3.Connection | None,
|
|
531
|
+
candidate_files: list[str] | None = None,
|
|
532
|
+
) -> str | None:
|
|
533
|
+
"""Return the file path of the most proximately-close declaring symbol.
|
|
534
|
+
|
|
535
|
+
Used for step D: AMBIGUOUS edge tie-break by file-path proximity.
|
|
536
|
+
Returns None when insufficient context is available.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
target_name: Symbol name to look up declaring files for.
|
|
540
|
+
referencing_file: The file that references the symbol (for proximity calc).
|
|
541
|
+
max_candidates: Maximum declaring files to evaluate (performance cap).
|
|
542
|
+
conn: DB connection for querying declaring files (may be None).
|
|
543
|
+
candidate_files: Pre-provided list of declaring file paths (for testing).
|
|
544
|
+
"""
|
|
545
|
+
if referencing_file is None:
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
# Get candidate declaring file paths (from conn or direct)
|
|
549
|
+
paths: list[str] = []
|
|
550
|
+
if candidate_files is not None:
|
|
551
|
+
paths = candidate_files[:max_candidates]
|
|
552
|
+
elif conn is not None:
|
|
553
|
+
try:
|
|
554
|
+
rows = conn.execute(
|
|
555
|
+
"""
|
|
556
|
+
SELECT DISTINCT f.path FROM symbols s
|
|
557
|
+
JOIN files f ON f.id = s.file_id
|
|
558
|
+
WHERE s.name = ?
|
|
559
|
+
LIMIT ?
|
|
560
|
+
""",
|
|
561
|
+
(target_name, max_candidates),
|
|
562
|
+
).fetchall()
|
|
563
|
+
paths = [row["path"] for row in rows]
|
|
564
|
+
except Exception: # noqa: BLE001
|
|
565
|
+
return None
|
|
566
|
+
|
|
567
|
+
if not paths:
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
# Rank by path proximity — higher score = same directory or closer tree
|
|
571
|
+
best_path: str | None = None
|
|
572
|
+
best_score: int = -1
|
|
573
|
+
for p in paths:
|
|
574
|
+
try:
|
|
575
|
+
score = compute_path_proximity(referencing_file, Path(p))
|
|
576
|
+
except Exception: # noqa: BLE001
|
|
577
|
+
score = 0
|
|
578
|
+
if score > best_score:
|
|
579
|
+
best_score = score
|
|
580
|
+
best_path = p
|
|
581
|
+
|
|
582
|
+
return best_path
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
# ── Public resolvers ───────────────────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def resolve_edge(
|
|
589
|
+
target_name: str,
|
|
590
|
+
name_counts: dict[str, int],
|
|
591
|
+
language: str | None = None,
|
|
592
|
+
import_mappings: list[ImportMapping] | None = None,
|
|
593
|
+
referencing_file: Path | None = None,
|
|
594
|
+
repo_root: Path | None = None,
|
|
595
|
+
conn: sqlite3.Connection | None = None,
|
|
596
|
+
max_import_candidates: int = 25,
|
|
597
|
+
max_proximity_candidates: int = 25,
|
|
598
|
+
candidate_files: list[str] | None = None,
|
|
599
|
+
) -> Resolution:
|
|
600
|
+
"""Full Phase 5 resolver: returns a Resolution with confidence + resolved_by.
|
|
601
|
+
|
|
602
|
+
Resolution order (four-step):
|
|
603
|
+
1. If import_mappings provided AND a non-wildcard mapping binds target_name
|
|
604
|
+
to exactly one indexed declaring file → EXTRACTED, 'import' (story A).
|
|
605
|
+
2. name-count rule:
|
|
606
|
+
count == 1 → EXTRACTED, 'name-unique'.
|
|
607
|
+
count > 1 → AMBIGUOUS, 'name-collision' + proximity best_candidate (D).
|
|
608
|
+
3. count == 0 AND is_builtin(name, language) → INFERRED, 'builtin' (C).
|
|
609
|
+
4. count == 0 AND not builtin → INFERRED, 'unresolved'.
|
|
610
|
+
|
|
611
|
+
The builtin check fires ONLY at count==0 (structural guarantee for story 5).
|
|
612
|
+
A user-defined name with count >= 1 is NEVER filtered as builtin.
|
|
613
|
+
|
|
614
|
+
Degrades gracefully: any missing context (no language, no mappings, etc.)
|
|
615
|
+
falls back to the name-count rule. Never raises.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
target_name: The edge target name to resolve.
|
|
619
|
+
name_counts: Whole-index name → count map (from load_name_counts).
|
|
620
|
+
language: Language of the referencing file. Required for builtin
|
|
621
|
+
check and import source resolution. None → no builtin check.
|
|
622
|
+
import_mappings: Parsed import bindings for the referencing file
|
|
623
|
+
(from load_import_mappings). None → skip step A.
|
|
624
|
+
referencing_file: Path to the referencing source file. Required for
|
|
625
|
+
import source resolution and proximity scoring.
|
|
626
|
+
repo_root: Repository root. Required for import source resolution.
|
|
627
|
+
conn: DB connection. Required for step A declaration check
|
|
628
|
+
and step D proximity query. None → limited resolution.
|
|
629
|
+
max_import_candidates: Cap on candidate declaring files per import (perf).
|
|
630
|
+
max_proximity_candidates: Cap on candidates for proximity ranking (perf).
|
|
631
|
+
candidate_files: Pre-provided candidate file paths for testing proximity
|
|
632
|
+
without a real DB connection.
|
|
633
|
+
"""
|
|
634
|
+
try:
|
|
635
|
+
# Step A: import promotion — beats global collision (the homonym fix).
|
|
636
|
+
# Requires SEAM_IMPORT_RESOLUTION="on" (default); skip when disabled.
|
|
637
|
+
if (
|
|
638
|
+
config.SEAM_IMPORT_RESOLUTION == "on"
|
|
639
|
+
and import_mappings is not None
|
|
640
|
+
and referencing_file is not None
|
|
641
|
+
and repo_root is not None
|
|
642
|
+
and conn is not None
|
|
643
|
+
and language is not None
|
|
644
|
+
):
|
|
645
|
+
return _resolve_with_import_promotion(
|
|
646
|
+
target_name=target_name,
|
|
647
|
+
name_counts=name_counts,
|
|
648
|
+
import_mappings=import_mappings,
|
|
649
|
+
referencing_file=referencing_file,
|
|
650
|
+
repo_root=repo_root,
|
|
651
|
+
conn=conn,
|
|
652
|
+
language=language,
|
|
653
|
+
max_import_candidates=max_import_candidates,
|
|
654
|
+
max_proximity_candidates=max_proximity_candidates,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
# Steps 2-4: name-count rule (+ builtin check at count==0)
|
|
658
|
+
return _resolve_name_count(
|
|
659
|
+
target_name=target_name,
|
|
660
|
+
name_counts=name_counts,
|
|
661
|
+
language=language,
|
|
662
|
+
referencing_file=referencing_file,
|
|
663
|
+
max_proximity_candidates=max_proximity_candidates,
|
|
664
|
+
conn=conn,
|
|
665
|
+
candidate_files=candidate_files,
|
|
666
|
+
)
|
|
667
|
+
except Exception: # noqa: BLE001
|
|
668
|
+
# Degrade gracefully: any failure falls back to plain name-count string
|
|
669
|
+
return Resolution(
|
|
670
|
+
confidence=resolve(target_name, name_counts),
|
|
671
|
+
resolved_by=None,
|
|
672
|
+
best_candidate=None,
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
# ── Pure resolver ──────────────────────────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def resolve(target_name: str, name_counts: dict[str, int]) -> str:
|
|
680
|
+
"""Resolve confidence for a single edge target against the whole-index name map.
|
|
681
|
+
|
|
682
|
+
Pure function — no I/O, no side effects.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
target_name: The edge target (callee / importee) name to resolve.
|
|
686
|
+
name_counts: Mapping produced by load_name_counts(conn).
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
CONFIDENCE_EXTRACTED if the name appears exactly once in the index.
|
|
690
|
+
CONFIDENCE_AMBIGUOUS if the name appears more than once.
|
|
691
|
+
CONFIDENCE_INFERRED if the name is absent (count == 0 or missing key).
|
|
692
|
+
"""
|
|
693
|
+
count = name_counts.get(target_name, 0)
|
|
694
|
+
if count == 1:
|
|
695
|
+
return CONFIDENCE_EXTRACTED
|
|
696
|
+
if count > 1:
|
|
697
|
+
return CONFIDENCE_AMBIGUOUS
|
|
698
|
+
# count == 0: name not in index — external library, stdlib, or dynamic call.
|
|
699
|
+
return CONFIDENCE_INFERRED
|