flowindex 0.1.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 (41) hide show
  1. flowindex/__init__.py +3 -0
  2. flowindex/cli.py +198 -0
  3. flowindex/config.py +181 -0
  4. flowindex/db/__init__.py +12 -0
  5. flowindex/db/migrations.py +12 -0
  6. flowindex/db/models.py +127 -0
  7. flowindex/db/session.py +45 -0
  8. flowindex/frameworks/__init__.py +0 -0
  9. flowindex/frameworks/django.py +43 -0
  10. flowindex/frameworks/express.py +44 -0
  11. flowindex/frameworks/fastapi.py +46 -0
  12. flowindex/frameworks/flask.py +50 -0
  13. flowindex/frameworks/nextjs.py +68 -0
  14. flowindex/indexer/__init__.py +5 -0
  15. flowindex/indexer/context_pack.py +163 -0
  16. flowindex/indexer/entrypoints.py +12 -0
  17. flowindex/indexer/explain.py +85 -0
  18. flowindex/indexer/git_history.py +146 -0
  19. flowindex/indexer/graph.py +194 -0
  20. flowindex/indexer/impact.py +244 -0
  21. flowindex/indexer/overview.py +44 -0
  22. flowindex/indexer/pipeline.py +129 -0
  23. flowindex/indexer/scanner.py +53 -0
  24. flowindex/indexer/symbols.py +121 -0
  25. flowindex/indexer/tests.py +90 -0
  26. flowindex/mcp/__init__.py +1 -0
  27. flowindex/mcp/server.py +52 -0
  28. flowindex/mcp/tools.py +106 -0
  29. flowindex/parsers/__init__.py +1 -0
  30. flowindex/parsers/base.py +16 -0
  31. flowindex/parsers/python_parser.py +160 -0
  32. flowindex/parsers/ts_parser.py +119 -0
  33. flowindex/render/__init__.py +0 -0
  34. flowindex/render/markdown.py +38 -0
  35. flowindex/render/tables.py +65 -0
  36. flowindex/schemas.py +126 -0
  37. flowindex-0.1.0.dist-info/METADATA +267 -0
  38. flowindex-0.1.0.dist-info/RECORD +41 -0
  39. flowindex-0.1.0.dist-info/WHEEL +4 -0
  40. flowindex-0.1.0.dist-info/entry_points.txt +2 -0
  41. flowindex-0.1.0.dist-info/licenses/LICENSE +21 -0
flowindex/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FlowIndex — behavior-first repository indexing for AI coding agents."""
2
+
3
+ __version__ = "0.1.0"
flowindex/cli.py ADDED
@@ -0,0 +1,198 @@
1
+ """FlowIndex CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from flowindex.config import find_project_root, load_config, write_default_config
11
+ from flowindex.db.migrations import migrate
12
+ from flowindex.db.session import get_session, init_db
13
+ from flowindex.indexer.context_pack import make_context_pack
14
+ from flowindex.indexer.explain import explain_target
15
+ from flowindex.indexer.impact import analyze_impact, suggest_tests
16
+ from flowindex.indexer.overview import build_overview
17
+ from flowindex.indexer.pipeline import run_scan
18
+ from flowindex.render.markdown import render_context_pack, render_explain
19
+ from flowindex.render.tables import print_impact, print_overview, print_scan_summary
20
+
21
+ app = typer.Typer(
22
+ name="flowindex",
23
+ help="Behavior-first repository indexing for AI coding agents.",
24
+ no_args_is_help=True,
25
+ )
26
+ console = Console()
27
+
28
+
29
+ @app.command()
30
+ def init(
31
+ path: Path | None = typer.Argument(None, help="Repository root (default: git root or cwd)"),
32
+ here: bool = typer.Option(False, "--here", help="Use current directory as repo root"),
33
+ ) -> None:
34
+ """Initialize FlowIndex in a repository."""
35
+ root = find_project_root(path, here=here)
36
+ config_path = write_default_config(root)
37
+ db_path = root / ".flowindex" / "flowindex.db"
38
+ init_db(db_path)
39
+ migrate(db_path)
40
+ console.print(f"[green]FlowIndex initialized[/green] at {root / '.flowindex'}")
41
+ console.print(f" Config: {config_path}")
42
+ console.print(f" Database: {db_path}")
43
+ console.print("\nNext: [bold]flowindex scan[/bold]")
44
+
45
+
46
+ @app.command()
47
+ def scan(
48
+ path: Path | None = typer.Argument(None, help="Repository root"),
49
+ here: bool = typer.Option(False, "--here", help="Use nearest .flowindex from cwd"),
50
+ ) -> None:
51
+ """Scan repository and build behavior index."""
52
+ try:
53
+ config = load_config(path)
54
+ except FileNotFoundError:
55
+ root = find_project_root(path, here=here)
56
+ console.print("[yellow]FlowIndex not initialized. Running init...[/yellow]")
57
+ write_default_config(root)
58
+ init_db(root / ".flowindex" / "flowindex.db")
59
+ config = load_config(root)
60
+
61
+ summary = run_scan(config)
62
+ print_scan_summary(summary.model_dump())
63
+
64
+
65
+ @app.command()
66
+ def overview(
67
+ path: Path | None = typer.Argument(None, help="Repository root"),
68
+ ) -> None:
69
+ """Show high-level repository map."""
70
+ config = load_config(path)
71
+ with get_session(config.db_path) as session:
72
+ from sqlmodel import select
73
+
74
+ from flowindex.db.models import Repository
75
+
76
+ repo = session.exec(
77
+ select(Repository).where(Repository.root_path == str(config.root_path.resolve()))
78
+ ).first()
79
+ if not repo or not repo.id:
80
+ console.print("[red]No indexed repository found. Run `flowindex scan`.[/red]")
81
+ raise typer.Exit(1)
82
+ data = build_overview(session, repo.id)
83
+ print_overview(data)
84
+
85
+
86
+ @app.command()
87
+ def explain(
88
+ target: str = typer.Argument(..., help="Entrypoint, symbol, or file path"),
89
+ path: Path | None = typer.Option(None, "--path", help="Repository root"),
90
+ ) -> None:
91
+ """Explain an entrypoint, symbol, or file flow."""
92
+ config = load_config(path)
93
+ with get_session(config.db_path) as session:
94
+ from sqlmodel import select
95
+
96
+ from flowindex.db.models import Repository
97
+
98
+ repo = session.exec(
99
+ select(Repository).where(Repository.root_path == str(config.root_path.resolve()))
100
+ ).first()
101
+ if not repo or not repo.id:
102
+ console.print("[red]No indexed repository. Run `flowindex scan`.[/red]")
103
+ raise typer.Exit(1)
104
+ result = explain_target(session, repo.id, target)
105
+ console.print(render_explain(result))
106
+
107
+
108
+ @app.command()
109
+ def impact(
110
+ target: str = typer.Argument(..., help="File path or symbol name"),
111
+ path: Path | None = typer.Option(None, "--path", help="Repository root"),
112
+ ) -> None:
113
+ """Show change impact and risk for a file or symbol."""
114
+ config = load_config(path)
115
+ with get_session(config.db_path) as session:
116
+ from sqlmodel import select
117
+
118
+ from flowindex.db.models import Repository
119
+
120
+ repo = session.exec(
121
+ select(Repository).where(Repository.root_path == str(config.root_path.resolve()))
122
+ ).first()
123
+ if not repo or not repo.id:
124
+ console.print("[red]No indexed repository. Run `flowindex scan`.[/red]")
125
+ raise typer.Exit(1)
126
+ result = analyze_impact(session, repo.id, target)
127
+ print_impact(result)
128
+
129
+
130
+ @app.command("tests-for")
131
+ def tests_for(
132
+ target: str = typer.Argument(..., help="File path or symbol name"),
133
+ path: Path | None = typer.Option(None, "--path", help="Repository root"),
134
+ ) -> None:
135
+ """Suggest tests to run for a change."""
136
+ config = load_config(path)
137
+ with get_session(config.db_path) as session:
138
+ from sqlmodel import select
139
+
140
+ from flowindex.db.models import Repository
141
+
142
+ repo = session.exec(
143
+ select(Repository).where(Repository.root_path == str(config.root_path.resolve()))
144
+ ).first()
145
+ if not repo or not repo.id:
146
+ console.print("[red]No indexed repository. Run `flowindex scan`.[/red]")
147
+ raise typer.Exit(1)
148
+ tests = suggest_tests(session, repo.id, target)
149
+ console.print(f"\n[bold]Suggested tests for:[/bold] {target}\n")
150
+ for t in tests or ["(none found)"]:
151
+ console.print(f" - {t}")
152
+ console.print()
153
+
154
+
155
+ @app.command()
156
+ def context(
157
+ task: str = typer.Argument(..., help="Natural language task description"),
158
+ path: Path | None = typer.Option(None, "--path", help="Repository root"),
159
+ ) -> None:
160
+ """Generate an AI-agent-ready context pack."""
161
+ config = load_config(path)
162
+ with get_session(config.db_path) as session:
163
+ from sqlmodel import select
164
+
165
+ from flowindex.db.models import Repository
166
+
167
+ repo = session.exec(
168
+ select(Repository).where(Repository.root_path == str(config.root_path.resolve()))
169
+ ).first()
170
+ if not repo or not repo.id:
171
+ console.print("[red]No indexed repository. Run `flowindex scan`.[/red]")
172
+ raise typer.Exit(1)
173
+ pack = make_context_pack(session, repo.id, task)
174
+ console.print(render_context_pack(pack))
175
+
176
+
177
+ @app.command()
178
+ def mcp(
179
+ path: Path | None = typer.Option(None, "--path", help="Repository root"),
180
+ ) -> None:
181
+ """Start the FlowIndex MCP server for AI coding agents."""
182
+ try:
183
+ from flowindex.mcp.server import run_server
184
+ except ImportError:
185
+ console.print(
186
+ "[red]MCP dependencies not installed.[/red] Run: pip install -e '.[mcp]'"
187
+ )
188
+ raise typer.Exit(1) from None
189
+ config = load_config(path)
190
+ run_server(config)
191
+
192
+
193
+ def main() -> None:
194
+ app()
195
+
196
+
197
+ if __name__ == "__main__":
198
+ main()
flowindex/config.py ADDED
@@ -0,0 +1,181 @@
1
+ """FlowIndex configuration and repository root detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import tomli_w
12
+
13
+ FLOWINDEX_DIR = ".flowindex"
14
+ CONFIG_FILE = "config.toml"
15
+ DB_FILE = "flowindex.db"
16
+
17
+ DEFAULT_EXCLUDES = [
18
+ ".git",
19
+ "node_modules",
20
+ ".venv",
21
+ "venv",
22
+ "dist",
23
+ "build",
24
+ ".next",
25
+ "__pycache__",
26
+ ".pytest_cache",
27
+ ".mypy_cache",
28
+ ".flowindex",
29
+ ]
30
+
31
+ DEFAULT_INCLUDES = ["**/*"]
32
+
33
+ DEFAULT_LANGUAGES = ["python", "typescript", "javascript"]
34
+
35
+ DEFAULT_TEST_DIRS = ["tests", "test", "__tests__", "spec"]
36
+
37
+ DEFAULT_CONFIG: dict[str, Any] = {
38
+ "included_paths": DEFAULT_INCLUDES,
39
+ "excluded_paths": DEFAULT_EXCLUDES,
40
+ "supported_languages": DEFAULT_LANGUAGES,
41
+ "test_directories": DEFAULT_TEST_DIRS,
42
+ "framework_detection": {
43
+ "fastapi": True,
44
+ "flask": True,
45
+ "django": True,
46
+ "express": True,
47
+ "nextjs": True,
48
+ },
49
+ "git": {
50
+ "max_commits": 500,
51
+ "changed_with_min_count": 2,
52
+ },
53
+ }
54
+
55
+
56
+ @dataclass
57
+ class FlowIndexConfig:
58
+ """Runtime configuration loaded from `.flowindex/config.toml`."""
59
+
60
+ root_path: Path
61
+ included_paths: list[str] = field(default_factory=lambda: list(DEFAULT_INCLUDES))
62
+ excluded_paths: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDES))
63
+ supported_languages: list[str] = field(default_factory=lambda: list(DEFAULT_LANGUAGES))
64
+ test_directories: list[str] = field(default_factory=lambda: list(DEFAULT_TEST_DIRS))
65
+ framework_detection: dict[str, bool] = field(
66
+ default_factory=lambda: dict(DEFAULT_CONFIG["framework_detection"])
67
+ )
68
+ git_max_commits: int = 500
69
+ git_changed_with_min_count: int = 2
70
+
71
+ @property
72
+ def flowindex_dir(self) -> Path:
73
+ return self.root_path / FLOWINDEX_DIR
74
+
75
+ @property
76
+ def db_path(self) -> Path:
77
+ return self.flowindex_dir / DB_FILE
78
+
79
+ @property
80
+ def config_path(self) -> Path:
81
+ return self.flowindex_dir / CONFIG_FILE
82
+
83
+ @classmethod
84
+ def from_dict(cls, root_path: Path, data: dict[str, Any]) -> FlowIndexConfig:
85
+ git = data.get("git", {})
86
+ return cls(
87
+ root_path=root_path.resolve(),
88
+ included_paths=list(data.get("included_paths", DEFAULT_INCLUDES)),
89
+ excluded_paths=list(data.get("excluded_paths", DEFAULT_EXCLUDES)),
90
+ supported_languages=list(data.get("supported_languages", DEFAULT_LANGUAGES)),
91
+ test_directories=list(data.get("test_directories", DEFAULT_TEST_DIRS)),
92
+ framework_detection=dict(
93
+ data.get("framework_detection", DEFAULT_CONFIG["framework_detection"])
94
+ ),
95
+ git_max_commits=int(git.get("max_commits", 500)),
96
+ git_changed_with_min_count=int(git.get("changed_with_min_count", 2)),
97
+ )
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ return {
101
+ "included_paths": self.included_paths,
102
+ "excluded_paths": self.excluded_paths,
103
+ "supported_languages": self.supported_languages,
104
+ "test_directories": self.test_directories,
105
+ "framework_detection": self.framework_detection,
106
+ "git": {
107
+ "max_commits": self.git_max_commits,
108
+ "changed_with_min_count": self.git_changed_with_min_count,
109
+ },
110
+ }
111
+
112
+
113
+ def find_repo_root(start: Path | None = None) -> Path:
114
+ """Walk up from start (or cwd) to find the nearest `.flowindex` directory."""
115
+ current = (start or Path.cwd()).resolve()
116
+ for path in [current, *current.parents]:
117
+ if (path / FLOWINDEX_DIR).is_dir():
118
+ return path
119
+ raise FileNotFoundError(
120
+ "FlowIndex is not initialized. Run `flowindex init` in your repository root."
121
+ )
122
+
123
+
124
+ def find_project_root(start: Path | None = None, *, here: bool = False) -> Path:
125
+ """Find git root or cwd for init/scan before `.flowindex` exists."""
126
+ current = (start or Path.cwd()).resolve()
127
+ if here:
128
+ return current
129
+ for path in [current, *current.parents]:
130
+ if (path / ".git").exists():
131
+ return path
132
+ return current
133
+
134
+
135
+ def load_config(root: Path | None = None) -> FlowIndexConfig:
136
+ root_path = find_repo_root(root)
137
+ config_path = root_path / FLOWINDEX_DIR / CONFIG_FILE
138
+ if not config_path.exists():
139
+ raise FileNotFoundError(f"Missing config at {config_path}. Run `flowindex init`.")
140
+ with config_path.open("rb") as f:
141
+ data = tomllib.load(f)
142
+ return FlowIndexConfig.from_dict(root_path, data)
143
+
144
+
145
+ def write_default_config(root_path: Path) -> Path:
146
+ """Create `.flowindex/config.toml` with defaults."""
147
+ flowindex_dir = root_path / FLOWINDEX_DIR
148
+ flowindex_dir.mkdir(parents=True, exist_ok=True)
149
+ config_path = flowindex_dir / CONFIG_FILE
150
+ with config_path.open("wb") as f:
151
+ tomli_w.dump(DEFAULT_CONFIG, f)
152
+ return config_path
153
+
154
+
155
+ def should_exclude(path: Path, root: Path, config: FlowIndexConfig) -> bool:
156
+ rel = path.relative_to(root).as_posix()
157
+ parts = rel.split("/")
158
+ for excluded in config.excluded_paths:
159
+ if excluded in parts or rel.startswith(excluded.rstrip("/")):
160
+ return True
161
+ if rel == excluded or rel.startswith(f"{excluded}/"):
162
+ return True
163
+ return False
164
+
165
+
166
+ def detect_language(path: Path) -> str | None:
167
+ ext = path.suffix.lower()
168
+ mapping = {
169
+ ".py": "python",
170
+ ".pyi": "python",
171
+ ".ts": "typescript",
172
+ ".tsx": "typescript",
173
+ ".js": "javascript",
174
+ ".jsx": "javascript",
175
+ ".mjs": "javascript",
176
+ ".cjs": "javascript",
177
+ }
178
+ lang = mapping.get(ext)
179
+ if lang == "typescript" and "javascript" in os.environ.get("FLOWINDEX_FORCE_JS", ""):
180
+ return "javascript"
181
+ return lang
@@ -0,0 +1,12 @@
1
+ """Simple schema migrations for FlowIndex SQLite database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from flowindex.db.session import init_db
8
+
9
+
10
+ def migrate(db_path: Path) -> None:
11
+ """Create or upgrade schema. MVP uses create_all."""
12
+ init_db(db_path)
@@ -0,0 +1,12 @@
1
+ """Simple schema migrations for FlowIndex SQLite database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from flowindex.db.session import init_db
8
+
9
+
10
+ def migrate(db_path: Path) -> None:
11
+ """Create or upgrade schema. MVP uses create_all."""
12
+ init_db(db_path)
flowindex/db/models.py ADDED
@@ -0,0 +1,127 @@
1
+ """SQLModel database models for FlowIndex."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+
7
+ from sqlmodel import Field, SQLModel
8
+
9
+
10
+ def utcnow() -> datetime:
11
+ return datetime.now(UTC)
12
+
13
+
14
+ class Repository(SQLModel, table=True):
15
+ id: int | None = Field(default=None, primary_key=True)
16
+ root_path: str
17
+ name: str
18
+ created_at: datetime = Field(default_factory=utcnow)
19
+ updated_at: datetime = Field(default_factory=utcnow)
20
+
21
+
22
+ class FileNode(SQLModel, table=True):
23
+ id: int | None = Field(default=None, primary_key=True)
24
+ repo_id: int = Field(foreign_key="repository.id", index=True)
25
+ path: str = Field(index=True)
26
+ language: str
27
+ size_bytes: int
28
+ content_hash: str
29
+ last_indexed_at: datetime = Field(default_factory=utcnow)
30
+
31
+
32
+ class SymbolNode(SQLModel, table=True):
33
+ id: int | None = Field(default=None, primary_key=True)
34
+ repo_id: int = Field(foreign_key="repository.id", index=True)
35
+ file_id: int = Field(foreign_key="filenode.id", index=True)
36
+ name: str = Field(index=True)
37
+ qualified_name: str = Field(index=True)
38
+ symbol_type: str = Field(index=True)
39
+ start_line: int
40
+ end_line: int
41
+ signature: str = ""
42
+ docstring: str = ""
43
+ visibility: str = "public"
44
+
45
+
46
+ class EntrypointNode(SQLModel, table=True):
47
+ id: int | None = Field(default=None, primary_key=True)
48
+ repo_id: int = Field(foreign_key="repository.id", index=True)
49
+ file_id: int = Field(foreign_key="filenode.id", index=True)
50
+ symbol_id: int | None = Field(default=None, foreign_key="symbolnode.id")
51
+ entrypoint_type: str = Field(index=True)
52
+ method: str | None = None
53
+ path: str | None = Field(default=None, index=True)
54
+ name: str
55
+ framework: str
56
+ start_line: int
57
+ end_line: int
58
+
59
+
60
+ class TestNode(SQLModel, table=True):
61
+ id: int | None = Field(default=None, primary_key=True)
62
+ repo_id: int = Field(foreign_key="repository.id", index=True)
63
+ file_id: int = Field(foreign_key="filenode.id", index=True)
64
+ symbol_id: int | None = Field(default=None, foreign_key="symbolnode.id")
65
+ test_name: str = Field(index=True)
66
+ framework: str
67
+ target_hint: str | None = None
68
+ start_line: int
69
+ end_line: int
70
+
71
+
72
+ class CommitNode(SQLModel, table=True):
73
+ id: int | None = Field(default=None, primary_key=True)
74
+ repo_id: int = Field(foreign_key="repository.id", index=True)
75
+ commit_hash: str = Field(index=True)
76
+ author: str
77
+ date: datetime
78
+ message: str
79
+
80
+
81
+ class GraphEdge(SQLModel, table=True):
82
+ id: int | None = Field(default=None, primary_key=True)
83
+ repo_id: int = Field(foreign_key="repository.id", index=True)
84
+ source_type: str
85
+ source_id: int
86
+ target_type: str
87
+ target_id: int
88
+ edge_type: str = Field(index=True)
89
+ weight: float = 1.0
90
+ evidence: str = ""
91
+ created_at: datetime = Field(default_factory=utcnow)
92
+
93
+
94
+ EDGE_TYPES = frozenset(
95
+ {
96
+ "defines",
97
+ "imports",
98
+ "calls",
99
+ "exposes",
100
+ "handled_by",
101
+ "tests",
102
+ "covers",
103
+ "changed_with",
104
+ "depends_on",
105
+ "reads",
106
+ "writes",
107
+ "mentions",
108
+ }
109
+ )
110
+
111
+ RISK_KEYWORDS = frozenset(
112
+ {
113
+ "fix",
114
+ "bug",
115
+ "regression",
116
+ "revert",
117
+ "failing",
118
+ "broken",
119
+ "incident",
120
+ "hotfix",
121
+ "flaky",
122
+ }
123
+ )
124
+
125
+ CRITICAL_PATH_KEYWORDS = frozenset(
126
+ {"auth", "payment", "billing", "db", "database", "security", "migration"}
127
+ )
@@ -0,0 +1,45 @@
1
+ """Database session management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+
9
+ from sqlalchemy.engine import Engine
10
+ from sqlmodel import Session, SQLModel, create_engine, select
11
+
12
+ from flowindex.db.models import (
13
+ CommitNode,
14
+ EntrypointNode,
15
+ FileNode,
16
+ GraphEdge,
17
+ SymbolNode,
18
+ TestNode,
19
+ )
20
+
21
+
22
+ def get_engine(db_path: Path) -> Engine:
23
+ db_path.parent.mkdir(parents=True, exist_ok=True)
24
+ return create_engine(f"sqlite:///{db_path}", echo=False)
25
+
26
+
27
+ def init_db(db_path: Path) -> None:
28
+ engine = get_engine(db_path)
29
+ SQLModel.metadata.create_all(engine)
30
+
31
+
32
+ @contextmanager
33
+ def get_session(db_path: Path) -> Iterator[Session]:
34
+ engine = get_engine(db_path)
35
+ with Session(engine) as session:
36
+ yield session
37
+
38
+
39
+ def clear_repo_data(session: Session, repo_id: int) -> None:
40
+ """Remove all indexed data for a repository before re-scan."""
41
+ for model in (GraphEdge, CommitNode, TestNode, EntrypointNode, SymbolNode, FileNode):
42
+ rows = session.exec(select(model).where(model.repo_id == repo_id)).all()
43
+ for row in rows:
44
+ session.delete(row)
45
+ session.commit()
File without changes
@@ -0,0 +1,43 @@
1
+ """Django view detection (heuristic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from flowindex.schemas import RouteInfo, SymbolInfo
8
+
9
+ PATH_DECORATOR = re.compile(r"@(?:path|re_path)\(\s*['\"]([^'\"]+)['\"]")
10
+
11
+
12
+ def detect_django_views(source: str, symbols: list[SymbolInfo]) -> list[RouteInfo]:
13
+ lines = source.splitlines()
14
+ routes: list[RouteInfo] = []
15
+ symbol_by_line = {s.start_line: s for s in symbols}
16
+
17
+ for i, line in enumerate(lines, 1):
18
+ m = PATH_DECORATOR.search(line)
19
+ if not m:
20
+ continue
21
+ handler = _next_function_name(lines, i, symbol_by_line)
22
+ routes.append(
23
+ RouteInfo(
24
+ method="GET",
25
+ path=m.group(1),
26
+ handler_name=handler,
27
+ framework="django",
28
+ start_line=i,
29
+ end_line=i,
30
+ )
31
+ )
32
+ return routes
33
+
34
+
35
+ def _next_function_name(lines: list[str], decor_line: int, symbols: dict[int, SymbolInfo]) -> str:
36
+ for j in range(decor_line, min(decor_line + 5, len(lines))):
37
+ sym = symbols.get(j + 1)
38
+ if sym:
39
+ return sym.name
40
+ stripped = lines[j].strip()
41
+ if stripped.startswith("def "):
42
+ return stripped.split("(")[0].replace("def ", "").strip()
43
+ return "view"
@@ -0,0 +1,44 @@
1
+ """Express route detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from flowindex.schemas import RouteInfo, SymbolInfo
8
+
9
+ EXPRESS_ROUTE = re.compile(
10
+ r"(?:app|router)\.(get|post|put|patch|delete|options|head)\(\s*['\"]([^'\"]+)['\"]",
11
+ re.IGNORECASE,
12
+ )
13
+
14
+
15
+ def detect_express_routes(source: str, symbols: list[SymbolInfo]) -> list[RouteInfo]:
16
+ routes: list[RouteInfo] = []
17
+ for m in EXPRESS_ROUTE.finditer(source):
18
+ start = source[: m.start()].count("\n") + 1
19
+ handler = _handler_near(source, m.end())
20
+ routes.append(
21
+ RouteInfo(
22
+ method=m.group(1).upper(),
23
+ path=m.group(2),
24
+ handler_name=handler,
25
+ framework="express",
26
+ start_line=start,
27
+ end_line=start,
28
+ )
29
+ )
30
+ return routes
31
+
32
+
33
+ def _handler_near(source: str, pos: int) -> str:
34
+ chunk = source[pos : pos + 200]
35
+ fn = re.search(r"(?:async\s+)?function\s+(\w+)", chunk)
36
+ if fn:
37
+ return fn.group(1)
38
+ arrow = re.search(r"(?:const|let)\s+(\w+)\s*=", chunk)
39
+ if arrow:
40
+ return arrow.group(1)
41
+ sym = re.search(r",\s*(\w+)\s*\)", chunk)
42
+ if sym:
43
+ return sym.group(1)
44
+ return "handler"