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,176 @@
|
|
|
1
|
+
"""P3 resolution helpers — tsconfig/jsconfig aliases + go.mod module prefix.
|
|
2
|
+
|
|
3
|
+
Leaf module — imports ONLY stdlib + the shared `_probe_extensions` helper from
|
|
4
|
+
seam.analysis.imports. Split out of imports.py purely to keep that file under
|
|
5
|
+
the 1000-line cap; the logic is part of the import-resolution path.
|
|
6
|
+
|
|
7
|
+
Provides (all cached per repo_root, all index-time, all never-raise):
|
|
8
|
+
_load_tsconfig_aliases(repo_root) -> dict[str, list[str]]
|
|
9
|
+
Read tsconfig.json / jsconfig.json `compilerOptions.paths` + `baseUrl`
|
|
10
|
+
into a longest-prefix-first alias map. {} on missing/malformed config.
|
|
11
|
+
_resolve_ts_alias(source_module, repo_root, extensions, probe) -> list[str]
|
|
12
|
+
Expand an aliased TS/JS specifier ('@/foo') to existing file paths.
|
|
13
|
+
_load_go_module(repo_root) -> str | None
|
|
14
|
+
Read the `module <path>` line from go.mod. None when absent.
|
|
15
|
+
|
|
16
|
+
Caches are single-repo (cleared when a different repo_root is seen) — the index
|
|
17
|
+
pipeline processes one repo at a time, so a 1-entry cache keeps memory bounded.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# ── P3: per-repo config caches (read ONCE per repo_root, zero read-time cost) ──
|
|
30
|
+
# _TSCONFIG_ALIAS_CACHE: {repo_root_str: {alias_pattern: [target_pattern, ...]}}
|
|
31
|
+
# alias/target patterns keep their trailing '/*' (or are exact, no '*').
|
|
32
|
+
# Insertion order is longest-prefix-first so resolution tries the most
|
|
33
|
+
# specific alias before the broader catch-all.
|
|
34
|
+
# _GO_MODULE_CACHE: {repo_root_str: module_path_or_None}
|
|
35
|
+
_TSCONFIG_ALIAS_CACHE: dict[str, dict[str, list[str]]] = {}
|
|
36
|
+
_GO_MODULE_CACHE: dict[str, str | None] = {}
|
|
37
|
+
|
|
38
|
+
# tsconfig allows // line and /* block */ comments — strip them before json.loads.
|
|
39
|
+
# Order matters: block comments first (may span lines), then line comments.
|
|
40
|
+
_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
|
|
41
|
+
_LINE_COMMENT_RE = re.compile(r"//[^\n\r]*")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _load_tsconfig_aliases(repo_root: Path) -> dict[str, list[str]]:
|
|
45
|
+
"""Read tsconfig.json / jsconfig.json paths+baseUrl into an alias map.
|
|
46
|
+
|
|
47
|
+
Returns a dict {alias_pattern: [resolved_target_pattern, ...]} where every
|
|
48
|
+
target is made relative to repo_root (baseUrl-joined). Insertion order is
|
|
49
|
+
longest-alias-first so the caller can try the most specific match first.
|
|
50
|
+
|
|
51
|
+
Cached per repo_root (the cache holds a single repo at a time). Never raises:
|
|
52
|
+
a missing or malformed config yields {} (degrades to current behavior).
|
|
53
|
+
"""
|
|
54
|
+
key = str(repo_root)
|
|
55
|
+
if key in _TSCONFIG_ALIAS_CACHE:
|
|
56
|
+
return _TSCONFIG_ALIAS_CACHE[key]
|
|
57
|
+
# A different repo_root is being indexed — drop the prior single-repo entry.
|
|
58
|
+
_TSCONFIG_ALIAS_CACHE.clear()
|
|
59
|
+
|
|
60
|
+
aliases: dict[str, list[str]] = {}
|
|
61
|
+
try:
|
|
62
|
+
config_path = None
|
|
63
|
+
for name in ("tsconfig.json", "jsconfig.json"):
|
|
64
|
+
candidate = repo_root / name
|
|
65
|
+
if candidate.exists():
|
|
66
|
+
config_path = candidate
|
|
67
|
+
break
|
|
68
|
+
if config_path is None:
|
|
69
|
+
_TSCONFIG_ALIAS_CACHE[key] = aliases
|
|
70
|
+
return aliases
|
|
71
|
+
|
|
72
|
+
raw = config_path.read_text(encoding="utf-8", errors="replace")
|
|
73
|
+
# Strip comments (tsconfig is JSONC) before parsing as strict JSON.
|
|
74
|
+
stripped = _BLOCK_COMMENT_RE.sub("", raw)
|
|
75
|
+
stripped = _LINE_COMMENT_RE.sub("", stripped)
|
|
76
|
+
data = json.loads(stripped)
|
|
77
|
+
|
|
78
|
+
compiler_opts = data.get("compilerOptions", {}) if isinstance(data, dict) else {}
|
|
79
|
+
base_url = compiler_opts.get("baseUrl", ".") or "."
|
|
80
|
+
paths = compiler_opts.get("paths", {})
|
|
81
|
+
if not isinstance(paths, dict):
|
|
82
|
+
_TSCONFIG_ALIAS_CACHE[key] = aliases
|
|
83
|
+
return aliases
|
|
84
|
+
|
|
85
|
+
for alias, targets in paths.items():
|
|
86
|
+
if not isinstance(alias, str) or not isinstance(targets, list):
|
|
87
|
+
continue
|
|
88
|
+
resolved_targets: list[str] = []
|
|
89
|
+
for target in targets:
|
|
90
|
+
if isinstance(target, str):
|
|
91
|
+
# baseUrl-join: 'src/*' under baseUrl '.' stays 'src/*'.
|
|
92
|
+
resolved_targets.append(f"{base_url}/{target}".replace("\\", "/"))
|
|
93
|
+
if resolved_targets:
|
|
94
|
+
aliases[alias] = resolved_targets
|
|
95
|
+
|
|
96
|
+
# Longest-prefix-first: '@/components/*' must beat '@/*'. Sort by the
|
|
97
|
+
# alias length WITHOUT the trailing '*' so specificity wins ties.
|
|
98
|
+
aliases = dict(
|
|
99
|
+
sorted(aliases.items(), key=lambda kv: len(kv[0].rstrip("*")), reverse=True)
|
|
100
|
+
)
|
|
101
|
+
except Exception as exc: # noqa: BLE001
|
|
102
|
+
logger.debug("_load_tsconfig_aliases: failed for %s: %r", repo_root, exc)
|
|
103
|
+
aliases = {}
|
|
104
|
+
|
|
105
|
+
_TSCONFIG_ALIAS_CACHE[key] = aliases
|
|
106
|
+
return aliases
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve_ts_alias(
|
|
110
|
+
source_module: str,
|
|
111
|
+
repo_root: Path,
|
|
112
|
+
extensions: list[str],
|
|
113
|
+
probe: Callable[[Path, list[str]], list[str]],
|
|
114
|
+
) -> list[str]:
|
|
115
|
+
"""Expand a tsconfig/jsconfig path alias to existing file paths.
|
|
116
|
+
|
|
117
|
+
Matches `source_module` against the longest-prefix-first alias map from
|
|
118
|
+
`_load_tsconfig_aliases`. A `<prefix>/*` alias captures the suffix after the
|
|
119
|
+
prefix and substitutes it into each `<target>/*` pattern; an exact alias (no
|
|
120
|
+
'*') maps the whole specifier. Each candidate base is probed (via the passed
|
|
121
|
+
`probe` helper) against the per-language extension order. Returns [] when no
|
|
122
|
+
alias matches. Never raises.
|
|
123
|
+
"""
|
|
124
|
+
aliases = _load_tsconfig_aliases(repo_root)
|
|
125
|
+
if not aliases:
|
|
126
|
+
return []
|
|
127
|
+
results: list[str] = []
|
|
128
|
+
for alias, targets in aliases.items():
|
|
129
|
+
if alias.endswith("/*"):
|
|
130
|
+
prefix = alias[:-1] # keep trailing '/', drop the '*'
|
|
131
|
+
if not source_module.startswith(prefix):
|
|
132
|
+
continue
|
|
133
|
+
suffix = source_module[len(prefix) :]
|
|
134
|
+
for target in targets:
|
|
135
|
+
# target like 'src/*' or './src/*' → substitute the captured suffix.
|
|
136
|
+
sub = target[:-1] + suffix if target.endswith("*") else target
|
|
137
|
+
base = Path(os.path.normpath(repo_root / sub))
|
|
138
|
+
results.extend(probe(base, extensions))
|
|
139
|
+
elif alias == source_module:
|
|
140
|
+
for target in targets:
|
|
141
|
+
base = Path(os.path.normpath(repo_root / target))
|
|
142
|
+
results.extend(probe(base, extensions))
|
|
143
|
+
if results:
|
|
144
|
+
# Longest-prefix-first ordering means the first matching alias is the
|
|
145
|
+
# most specific — stop once it yields hits.
|
|
146
|
+
return results
|
|
147
|
+
return results
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _load_go_module(repo_root: Path) -> str | None:
|
|
151
|
+
"""Read the `module <path>` line from go.mod at repo_root.
|
|
152
|
+
|
|
153
|
+
Returns the module path (e.g. 'github.com/org/repo') or None when go.mod is
|
|
154
|
+
absent / has no module line. Cached per repo_root. Never raises.
|
|
155
|
+
"""
|
|
156
|
+
key = str(repo_root)
|
|
157
|
+
if key in _GO_MODULE_CACHE:
|
|
158
|
+
return _GO_MODULE_CACHE[key]
|
|
159
|
+
# Different repo_root — drop the prior single-repo entry.
|
|
160
|
+
_GO_MODULE_CACHE.clear()
|
|
161
|
+
|
|
162
|
+
module_path: str | None = None
|
|
163
|
+
try:
|
|
164
|
+
go_mod = repo_root / "go.mod"
|
|
165
|
+
if go_mod.exists():
|
|
166
|
+
for line in go_mod.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
167
|
+
stripped = line.strip()
|
|
168
|
+
if stripped.startswith("module "):
|
|
169
|
+
module_path = stripped[len("module ") :].strip()
|
|
170
|
+
break
|
|
171
|
+
except Exception as exc: # noqa: BLE001
|
|
172
|
+
logger.debug("_load_go_module: failed for %s: %r", repo_root, exc)
|
|
173
|
+
module_path = None
|
|
174
|
+
|
|
175
|
+
_GO_MODULE_CACHE[key] = module_path
|
|
176
|
+
return module_path
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Execution flows — entry points + forward call-chain expansion.
|
|
2
|
+
|
|
3
|
+
Answers "how does feature X work end-to-end?" — the comprehension question the
|
|
4
|
+
structural index can answer deterministically (no LLM, no embeddings).
|
|
5
|
+
|
|
6
|
+
Contract
|
|
7
|
+
--------
|
|
8
|
+
``list_entry_points(conn, *, limit, repo_root=None) -> list[EntryPoint]``
|
|
9
|
+
|
|
10
|
+
Entry points = DEFINED, non-test symbols that are call-graph ROOTS (no incoming
|
|
11
|
+
call edges), ranked by downstream reach (how many symbols they transitively
|
|
12
|
+
call). On a real repo these surface exactly the program's starting points —
|
|
13
|
+
CLI commands, web routes, MCP handlers, ``main`` — with zero heuristics on
|
|
14
|
+
decorators or names (validated: decorators are often empty, and raw roots are
|
|
15
|
+
too noisy; reach-ranking is the signal that works).
|
|
16
|
+
|
|
17
|
+
``build_flow(conn, entry, *, max_depth, max_breadth, repo_root=None) -> Flow | None``
|
|
18
|
+
|
|
19
|
+
Forward expansion from ``entry`` over call/import edges into a depth- and
|
|
20
|
+
breadth-capped, cycle-safe tree of steps. ``None`` if ``entry`` is unknown.
|
|
21
|
+
|
|
22
|
+
Both NEVER raise — return ``[]`` / ``None`` on any DB error.
|
|
23
|
+
|
|
24
|
+
Confidence note
|
|
25
|
+
---------------
|
|
26
|
+
Per-step confidence uses the fast name-count resolver (``confidence.resolve``),
|
|
27
|
+
NOT the Phase-5 import-promotion path. A flow is an *overview*; for promoted,
|
|
28
|
+
import-aware confidence use ``seam_impact`` / ``seam_trace``. This keeps flow
|
|
29
|
+
building to a few bulk queries instead of per-node resolution.
|
|
30
|
+
|
|
31
|
+
Layer: seam.analysis — imports stdlib + seam.config + seam.analysis.{confidence,
|
|
32
|
+
testpaths} only. No server/cli/query imports.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
import re
|
|
37
|
+
import sqlite3
|
|
38
|
+
from collections import deque
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import TypedDict
|
|
41
|
+
|
|
42
|
+
import seam.config as config
|
|
43
|
+
from seam.analysis.confidence import load_name_counts, resolve
|
|
44
|
+
from seam.analysis.testpaths import is_test_file
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"EntryPoint",
|
|
50
|
+
"FlowStep",
|
|
51
|
+
"Flow",
|
|
52
|
+
"list_entry_points",
|
|
53
|
+
"build_flow",
|
|
54
|
+
"compute_entry_score",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── P6b: framework entry-point scoring ───────────────────────────────────────
|
|
59
|
+
#
|
|
60
|
+
# A framework entry point (a Django view, a Flask/FastAPI route, a Go cmd/main,
|
|
61
|
+
# a Rails controller) often has a SHALLOW downstream reach — it delegates to one
|
|
62
|
+
# service call — yet it IS the program's true starting point. Raw-reach ranking
|
|
63
|
+
# buries it under deep internal utilities. We multiply reach by a framework-aware
|
|
64
|
+
# entry_score computed once at INDEX time from two cheap, language-agnostic signals:
|
|
65
|
+
# (1) the file's PATH — conventional locations of request handlers / entry points;
|
|
66
|
+
# (2) the symbol's DECORATOR text — route/handler decorators across frameworks.
|
|
67
|
+
# The score is a small multiplier (>=1.0); 1.0 is the neutral baseline so a symbol
|
|
68
|
+
# with no signal ranks exactly as before (byte-identical when nothing matches).
|
|
69
|
+
|
|
70
|
+
# Path substrings → multiplier. Checked case-insensitively against the POSIX path.
|
|
71
|
+
# Ordered roughly by specificity; the HIGHEST matching multiplier wins (max, not sum)
|
|
72
|
+
# so a file under both 'app/api/' and 'routes/' is not double-counted.
|
|
73
|
+
_PATH_PATTERNS: tuple[tuple[str, float], ...] = (
|
|
74
|
+
("/pages/", 1.8), # Next.js / SvelteKit file-system routes
|
|
75
|
+
("/app/api/", 1.8), # Next.js app-router API handlers
|
|
76
|
+
("/routes/", 1.7), # generic web routes dir
|
|
77
|
+
("/controllers/", 1.7), # Rails / Spring / NestJS controllers
|
|
78
|
+
("/handlers/", 1.6), # Go / serverless handlers
|
|
79
|
+
("/views/", 1.6), # Django/Flask views package
|
|
80
|
+
("views.py", 1.7), # Django views module
|
|
81
|
+
("urls.py", 1.5), # Django URL conf (entry registration)
|
|
82
|
+
("routes.py", 1.7), # Flask/FastAPI routes module
|
|
83
|
+
("/endpoints/", 1.6), # FastAPI endpoints package
|
|
84
|
+
("/api/", 1.5), # generic api dir
|
|
85
|
+
("/cmd/", 1.6), # Go command entry points
|
|
86
|
+
("/commands/", 1.5), # CLI command modules
|
|
87
|
+
("/cli/", 1.4), # CLI package
|
|
88
|
+
("main.go", 1.6), # Go main
|
|
89
|
+
("main.py", 1.5), # Python main module
|
|
90
|
+
("main.rs", 1.5), # Rust main
|
|
91
|
+
("app.py", 1.4), # Flask app entry
|
|
92
|
+
("server.py", 1.4), # server entry module
|
|
93
|
+
("/resolvers/", 1.5), # GraphQL resolvers
|
|
94
|
+
("index.ts", 1.3), # TS package entry / route file
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Decorator substrings → multiplier. Matched case-insensitively against the joined
|
|
98
|
+
# decorator text. Covers the common web/CLI frameworks across Python/TS/Java/etc.
|
|
99
|
+
_DECORATOR_PATTERNS: tuple[tuple[str, float], ...] = (
|
|
100
|
+
(".route", 1.8), # @app.route (Flask), @router.route
|
|
101
|
+
("@router.", 1.8), # @router.get/post/... (FastAPI)
|
|
102
|
+
("@app.", 1.7), # @app.get/post/... (FastAPI), @app.command (Typer)
|
|
103
|
+
(".command", 1.6), # @app.command (Typer), @cli.command (Click)
|
|
104
|
+
("getmapping", 1.7), # @GetMapping (Spring)
|
|
105
|
+
("postmapping", 1.7), # @PostMapping (Spring)
|
|
106
|
+
("requestmapping", 1.7), # @RequestMapping (Spring)
|
|
107
|
+
("restcontroller", 1.7), # @RestController (Spring)
|
|
108
|
+
("@controller", 1.6), # @Controller (Spring/NestJS)
|
|
109
|
+
("@get(", 1.6), # @Get() (NestJS)
|
|
110
|
+
("@post(", 1.6), # @Post() (NestJS)
|
|
111
|
+
("@api_view", 1.6), # @api_view (Django REST framework)
|
|
112
|
+
("@task", 1.4), # @task (Celery) — background entry point
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def compute_entry_score(
|
|
117
|
+
file_path: str | None, decorators: list[str] | None
|
|
118
|
+
) -> float:
|
|
119
|
+
"""Framework-aware entry-point multiplier for one symbol (computed at index time).
|
|
120
|
+
|
|
121
|
+
Returns the MAX matching multiplier across the file-path patterns and the
|
|
122
|
+
decorator patterns, defaulting to the neutral baseline 1.0 when nothing
|
|
123
|
+
matches (so a non-entry symbol ranks exactly as raw reach would).
|
|
124
|
+
|
|
125
|
+
Pure + defensive: NEVER raises. Bad input (None / wrong type) → 1.0.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
file_path: Declaring file path (any OS form; matched as POSIX, lowercased).
|
|
129
|
+
decorators: Symbol decorator strings (e.g. ['@app.route("/x")']); may be None.
|
|
130
|
+
"""
|
|
131
|
+
score = 1.0
|
|
132
|
+
try:
|
|
133
|
+
if isinstance(file_path, str) and file_path:
|
|
134
|
+
posix = file_path.replace("\\", "/").lower()
|
|
135
|
+
for pattern, mult in _PATH_PATTERNS:
|
|
136
|
+
if pattern in posix and mult > score:
|
|
137
|
+
score = mult
|
|
138
|
+
if isinstance(decorators, list) and decorators:
|
|
139
|
+
text = " ".join(d for d in decorators if isinstance(d, str)).lower()
|
|
140
|
+
text = re.sub(r"\s+", "", text) # collapse whitespace for robust substring match
|
|
141
|
+
for pattern, mult in _DECORATOR_PATTERNS:
|
|
142
|
+
if pattern in text and mult > score:
|
|
143
|
+
score = mult
|
|
144
|
+
except Exception: # noqa: BLE001 — scoring must never break indexing
|
|
145
|
+
logger.debug("compute_entry_score failed for %r — using baseline", file_path)
|
|
146
|
+
return 1.0
|
|
147
|
+
return score
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ── Types ────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class EntryPoint(TypedDict):
|
|
154
|
+
"""A program entry point — a call-graph root ranked by downstream reach."""
|
|
155
|
+
|
|
156
|
+
name: str
|
|
157
|
+
kind: str | None
|
|
158
|
+
file: str | None # relativized when repo_root is provided
|
|
159
|
+
reach: int # distinct symbols reachable downstream (bounded by SEAM_FLOW_REACH_DEPTH)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class FlowStep(TypedDict):
|
|
163
|
+
"""One node in a flow tree (a symbol the entry point calls, directly or transitively).
|
|
164
|
+
|
|
165
|
+
Fields:
|
|
166
|
+
name — symbol name
|
|
167
|
+
kind — symbol kind (function/method/class/…), None if undefined in index
|
|
168
|
+
file — declaring file (relativized when repo_root given), None if undefined
|
|
169
|
+
line — start line of the declaration, None if undefined
|
|
170
|
+
confidence — name-count confidence of the edge reaching this step
|
|
171
|
+
children — nested callees (empty at a leaf or a cut boundary)
|
|
172
|
+
truncated — True when this node's children were cut by depth/breadth caps
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
name: str
|
|
176
|
+
kind: str | None
|
|
177
|
+
file: str | None
|
|
178
|
+
line: int | None
|
|
179
|
+
confidence: str
|
|
180
|
+
children: list["FlowStep"]
|
|
181
|
+
truncated: bool
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Flow(TypedDict):
|
|
185
|
+
"""A full execution flow rooted at one entry point."""
|
|
186
|
+
|
|
187
|
+
entry: str
|
|
188
|
+
kind: str | None
|
|
189
|
+
file: str | None
|
|
190
|
+
steps: list[FlowStep] # the entry point's direct callees, each with its own subtree
|
|
191
|
+
total_steps: int # count of every FlowStep node in the tree
|
|
192
|
+
truncated: bool # True if any cap was hit anywhere in the tree
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── Internal loaders (one bulk query each — no per-node queries) ─────────────
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _load_adjacency(
|
|
199
|
+
conn: sqlite3.Connection,
|
|
200
|
+
) -> tuple[dict[str, list[tuple[str, str]]], set[str]]:
|
|
201
|
+
"""Load the downstream call graph once.
|
|
202
|
+
|
|
203
|
+
Returns (adjacency, all_targets):
|
|
204
|
+
adjacency — name -> sorted list of (callee_name, edge_kind), deduped.
|
|
205
|
+
all_targets — set of every target_name (used to detect roots: a root is a
|
|
206
|
+
source that is never a target).
|
|
207
|
+
Self-edges (source == target) are excluded.
|
|
208
|
+
"""
|
|
209
|
+
adjacency: dict[str, list[tuple[str, str]]] = {}
|
|
210
|
+
all_targets: set[str] = set()
|
|
211
|
+
seen: set[tuple[str, str, str]] = set()
|
|
212
|
+
for row in conn.execute(
|
|
213
|
+
"SELECT source_name, target_name, kind FROM edges WHERE source_name != target_name"
|
|
214
|
+
):
|
|
215
|
+
src, tgt, kind = row["source_name"], row["target_name"], row["kind"]
|
|
216
|
+
all_targets.add(tgt)
|
|
217
|
+
dedup_key = (src, tgt, kind)
|
|
218
|
+
if dedup_key in seen:
|
|
219
|
+
continue
|
|
220
|
+
seen.add(dedup_key)
|
|
221
|
+
adjacency.setdefault(src, []).append((tgt, kind))
|
|
222
|
+
for src in adjacency:
|
|
223
|
+
adjacency[src].sort() # deterministic callee order
|
|
224
|
+
return adjacency, all_targets
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _load_meta(
|
|
228
|
+
conn: sqlite3.Connection,
|
|
229
|
+
) -> dict[str, tuple[str | None, str | None, int | None]]:
|
|
230
|
+
"""name -> (kind, file_path, start_line) from the lowest-id defining row.
|
|
231
|
+
|
|
232
|
+
Homonym-collapse: a name maps to its lowest-id definition, matching the
|
|
233
|
+
name-keyed edges table and the rest of the read path.
|
|
234
|
+
"""
|
|
235
|
+
meta: dict[str, tuple[str | None, str | None, int | None]] = {}
|
|
236
|
+
for row in conn.execute(
|
|
237
|
+
"SELECT s.name, s.kind, f.path, s.start_line "
|
|
238
|
+
"FROM symbols s JOIN files f ON f.id = s.file_id ORDER BY s.id"
|
|
239
|
+
):
|
|
240
|
+
name = row["name"]
|
|
241
|
+
if name not in meta: # lowest id wins (rows ordered by s.id)
|
|
242
|
+
meta[name] = (row["kind"], row["path"], row["start_line"])
|
|
243
|
+
return meta
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _load_entry_scores(conn: sqlite3.Connection) -> dict[str, float]:
|
|
247
|
+
"""name -> entry_score from the lowest-id defining row (homonym-collapse).
|
|
248
|
+
|
|
249
|
+
Mirrors _load_meta's lowest-id-wins rule so a name's score matches the
|
|
250
|
+
definition the rest of the read path uses. NULL (pre-P6b or un-reindexed
|
|
251
|
+
rows) → 1.0 neutral baseline, so missing scores never penalise ranking.
|
|
252
|
+
|
|
253
|
+
Returns {} when the entry_score column is absent (pre-v9 index) — callers
|
|
254
|
+
then fall back to the baseline for every name.
|
|
255
|
+
"""
|
|
256
|
+
scores: dict[str, float] = {}
|
|
257
|
+
try:
|
|
258
|
+
for row in conn.execute(
|
|
259
|
+
"SELECT name, entry_score FROM symbols ORDER BY id"
|
|
260
|
+
):
|
|
261
|
+
name = row["name"]
|
|
262
|
+
if name not in scores: # lowest id wins
|
|
263
|
+
scores[name] = row["entry_score"] if row["entry_score"] is not None else 1.0
|
|
264
|
+
except sqlite3.Error:
|
|
265
|
+
# entry_score column absent (pre-v9) — return empty so callers use 1.0.
|
|
266
|
+
logger.debug("_load_entry_scores: column absent or DB error — baseline", exc_info=True)
|
|
267
|
+
return {}
|
|
268
|
+
return scores
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _rel(path: str | None, repo_root: Path | None) -> str | None:
|
|
272
|
+
"""Relativize a path to repo_root; pass through if not under root or root is None."""
|
|
273
|
+
if path is None or repo_root is None:
|
|
274
|
+
return path
|
|
275
|
+
try:
|
|
276
|
+
return str(Path(path).relative_to(repo_root))
|
|
277
|
+
except ValueError:
|
|
278
|
+
return path
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _reach(seed: str, adjacency: dict[str, list[tuple[str, str]]], max_depth: int) -> int:
|
|
282
|
+
"""Count distinct symbols reachable downstream from seed within max_depth hops.
|
|
283
|
+
|
|
284
|
+
Bounded BFS — this is a RANKING signal for entry points, not a full walk.
|
|
285
|
+
"""
|
|
286
|
+
seen: set[str] = set()
|
|
287
|
+
queue: deque[tuple[str, int]] = deque([(seed, 0)])
|
|
288
|
+
while queue:
|
|
289
|
+
name, depth = queue.popleft()
|
|
290
|
+
if depth >= max_depth:
|
|
291
|
+
continue
|
|
292
|
+
for target, _kind in adjacency.get(name, ()):
|
|
293
|
+
if target not in seen:
|
|
294
|
+
seen.add(target)
|
|
295
|
+
queue.append((target, depth + 1))
|
|
296
|
+
return len(seen)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _count_steps(steps: list[FlowStep]) -> int:
|
|
300
|
+
"""Total FlowStep nodes in a tree (each node counts itself + its subtree)."""
|
|
301
|
+
return sum(1 + _count_steps(s["children"]) for s in steps)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ── Public interface ─────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def list_entry_points(
|
|
308
|
+
conn: sqlite3.Connection,
|
|
309
|
+
*,
|
|
310
|
+
limit: int = config.SEAM_FLOW_ENTRY_LIMIT,
|
|
311
|
+
repo_root: Path | None = None,
|
|
312
|
+
) -> list[EntryPoint]:
|
|
313
|
+
"""Return the program's entry points: defined non-test roots ranked by reach.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
conn: Open SQLite connection (read-only).
|
|
317
|
+
limit: Max entry points to return.
|
|
318
|
+
repo_root: When provided, entry-point file paths are relativized to it.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
list[EntryPoint] sorted by reach desc, then name. Empty list on any DB
|
|
322
|
+
error or an index with no edges. NEVER raises.
|
|
323
|
+
"""
|
|
324
|
+
try:
|
|
325
|
+
adjacency, all_targets = _load_adjacency(conn)
|
|
326
|
+
meta = _load_meta(conn)
|
|
327
|
+
entry_scores = _load_entry_scores(conn)
|
|
328
|
+
except sqlite3.Error:
|
|
329
|
+
logger.debug("list_entry_points: DB error — returning []", exc_info=True)
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
# Roots = defined symbols that are graph sources but never targets (uncalled),
|
|
333
|
+
# excluding tests (a test runner is not a meaningful program entry point).
|
|
334
|
+
roots = [
|
|
335
|
+
name
|
|
336
|
+
for name in adjacency
|
|
337
|
+
if name not in all_targets and name in meta and not is_test_file(meta[name][1])
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
# P6b: rank by framework-aware weighted reach (entry_score * reach) instead of
|
|
341
|
+
# raw reach, so a low-reach route/view/controller outranks a deep utility.
|
|
342
|
+
# entry_score is pre-computed at INDEX time (one column read, no BFS re-run).
|
|
343
|
+
# SEAM_ENTRY_SCORE=off → every weight is the baseline 1.0 → byte-identical to
|
|
344
|
+
# raw-reach ranking. The 'reach' field stays the RAW reach (the multiplier is a
|
|
345
|
+
# ranking signal only — callers still see the true downstream count).
|
|
346
|
+
use_scores = config.SEAM_ENTRY_SCORE == "on"
|
|
347
|
+
|
|
348
|
+
def _weight(name: str, reach: int) -> float:
|
|
349
|
+
score = entry_scores.get(name, 1.0) if use_scores else 1.0
|
|
350
|
+
return score * reach
|
|
351
|
+
|
|
352
|
+
scored = sorted(
|
|
353
|
+
(
|
|
354
|
+
EntryPoint(
|
|
355
|
+
name=name,
|
|
356
|
+
kind=meta[name][0],
|
|
357
|
+
file=_rel(meta[name][1], repo_root),
|
|
358
|
+
reach=_reach(name, adjacency, config.SEAM_FLOW_REACH_DEPTH),
|
|
359
|
+
)
|
|
360
|
+
for name in roots
|
|
361
|
+
),
|
|
362
|
+
key=lambda e: (-_weight(e["name"], e["reach"]), e["name"]),
|
|
363
|
+
)
|
|
364
|
+
return scored[:limit]
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def build_flow(
|
|
368
|
+
conn: sqlite3.Connection,
|
|
369
|
+
entry: str,
|
|
370
|
+
*,
|
|
371
|
+
max_depth: int = config.SEAM_FLOW_MAX_DEPTH,
|
|
372
|
+
max_breadth: int = config.SEAM_FLOW_MAX_BREADTH,
|
|
373
|
+
repo_root: Path | None = None,
|
|
374
|
+
) -> Flow | None:
|
|
375
|
+
"""Expand a single execution flow rooted at `entry`.
|
|
376
|
+
|
|
377
|
+
Walks call/import edges forward from `entry`, building a depth/breadth-capped,
|
|
378
|
+
cycle-safe tree. Each symbol appears at most once (first reach wins — keeps the
|
|
379
|
+
tree readable and bounded, matching the homonym-collapse semantics elsewhere).
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
conn: Open SQLite connection (read-only).
|
|
383
|
+
entry: Entry-point symbol name.
|
|
384
|
+
max_depth: Max levels of callees to expand (cut beyond → truncated=True).
|
|
385
|
+
max_breadth: Max callees shown per node (excess dropped → truncated=True).
|
|
386
|
+
repo_root: When provided, file paths are relativized to it.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
A Flow tree, or None if `entry` is unknown (not a symbol and not in the
|
|
390
|
+
call graph). NEVER raises.
|
|
391
|
+
"""
|
|
392
|
+
try:
|
|
393
|
+
adjacency, all_targets = _load_adjacency(conn)
|
|
394
|
+
meta = _load_meta(conn)
|
|
395
|
+
name_counts = load_name_counts(conn)
|
|
396
|
+
except sqlite3.Error:
|
|
397
|
+
logger.debug("build_flow(%r): DB error — returning None", entry, exc_info=True)
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
# Unknown entry: not defined and absent from the graph entirely.
|
|
401
|
+
if entry not in meta and entry not in adjacency and entry not in all_targets:
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
any_truncated = False
|
|
405
|
+
visited: set[str] = {entry}
|
|
406
|
+
|
|
407
|
+
def expand(name: str, depth: int) -> tuple[list[FlowStep], bool]:
|
|
408
|
+
"""Return (child steps of `name`, whether `name`'s own children were cut)."""
|
|
409
|
+
nonlocal any_truncated
|
|
410
|
+
# Only unvisited callees (cycle + repeat-subtree safety).
|
|
411
|
+
edges = [(t, k) for (t, k) in adjacency.get(name, []) if t not in visited]
|
|
412
|
+
if not edges:
|
|
413
|
+
return [], False
|
|
414
|
+
# Children would sit at depth+1; cut entirely if that exceeds max_depth.
|
|
415
|
+
if depth + 1 > max_depth:
|
|
416
|
+
any_truncated = True
|
|
417
|
+
return [], True
|
|
418
|
+
node_truncated = False
|
|
419
|
+
if len(edges) > max_breadth:
|
|
420
|
+
node_truncated = True
|
|
421
|
+
any_truncated = True
|
|
422
|
+
edges = edges[:max_breadth]
|
|
423
|
+
# Reserve all direct children BEFORE recursing so a callee appears once
|
|
424
|
+
# (under its first parent) rather than duplicated across siblings.
|
|
425
|
+
for target, _kind in edges:
|
|
426
|
+
visited.add(target)
|
|
427
|
+
steps: list[FlowStep] = []
|
|
428
|
+
for target, _kind in edges:
|
|
429
|
+
kind, path, line = meta.get(target, (None, None, None))
|
|
430
|
+
child_steps, child_truncated = expand(target, depth + 1)
|
|
431
|
+
steps.append(
|
|
432
|
+
FlowStep(
|
|
433
|
+
name=target,
|
|
434
|
+
kind=kind,
|
|
435
|
+
file=_rel(path, repo_root),
|
|
436
|
+
line=line,
|
|
437
|
+
confidence=resolve(target, name_counts),
|
|
438
|
+
children=child_steps,
|
|
439
|
+
truncated=child_truncated,
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
return steps, node_truncated
|
|
443
|
+
|
|
444
|
+
steps, _root_truncated = expand(entry, 0)
|
|
445
|
+
entry_kind, entry_path, _ = meta.get(entry, (None, None, None))
|
|
446
|
+
return Flow(
|
|
447
|
+
entry=entry,
|
|
448
|
+
kind=entry_kind,
|
|
449
|
+
file=_rel(entry_path, repo_root),
|
|
450
|
+
steps=steps,
|
|
451
|
+
total_steps=_count_steps(steps),
|
|
452
|
+
truncated=any_truncated,
|
|
453
|
+
)
|