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.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. 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
+ )