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.
- flowindex/__init__.py +3 -0
- flowindex/cli.py +198 -0
- flowindex/config.py +181 -0
- flowindex/db/__init__.py +12 -0
- flowindex/db/migrations.py +12 -0
- flowindex/db/models.py +127 -0
- flowindex/db/session.py +45 -0
- flowindex/frameworks/__init__.py +0 -0
- flowindex/frameworks/django.py +43 -0
- flowindex/frameworks/express.py +44 -0
- flowindex/frameworks/fastapi.py +46 -0
- flowindex/frameworks/flask.py +50 -0
- flowindex/frameworks/nextjs.py +68 -0
- flowindex/indexer/__init__.py +5 -0
- flowindex/indexer/context_pack.py +163 -0
- flowindex/indexer/entrypoints.py +12 -0
- flowindex/indexer/explain.py +85 -0
- flowindex/indexer/git_history.py +146 -0
- flowindex/indexer/graph.py +194 -0
- flowindex/indexer/impact.py +244 -0
- flowindex/indexer/overview.py +44 -0
- flowindex/indexer/pipeline.py +129 -0
- flowindex/indexer/scanner.py +53 -0
- flowindex/indexer/symbols.py +121 -0
- flowindex/indexer/tests.py +90 -0
- flowindex/mcp/__init__.py +1 -0
- flowindex/mcp/server.py +52 -0
- flowindex/mcp/tools.py +106 -0
- flowindex/parsers/__init__.py +1 -0
- flowindex/parsers/base.py +16 -0
- flowindex/parsers/python_parser.py +160 -0
- flowindex/parsers/ts_parser.py +119 -0
- flowindex/render/__init__.py +0 -0
- flowindex/render/markdown.py +38 -0
- flowindex/render/tables.py +65 -0
- flowindex/schemas.py +126 -0
- flowindex-0.1.0.dist-info/METADATA +267 -0
- flowindex-0.1.0.dist-info/RECORD +41 -0
- flowindex-0.1.0.dist-info/WHEEL +4 -0
- flowindex-0.1.0.dist-info/entry_points.txt +2 -0
- flowindex-0.1.0.dist-info/licenses/LICENSE +21 -0
flowindex/__init__.py
ADDED
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
|
flowindex/db/__init__.py
ADDED
|
@@ -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
|
+
)
|
flowindex/db/session.py
ADDED
|
@@ -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"
|