scip-cli 2.0.0__tar.gz → 2.1.0__tar.gz
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.
- {scip_cli-2.0.0 → scip_cli-2.1.0}/.cursor/rules/agent.mdc +43 -11
- {scip_cli-2.0.0 → scip_cli-2.1.0}/PKG-INFO +12 -5
- {scip_cli-2.0.0 → scip_cli-2.1.0}/README.md +11 -4
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/SKILL.md +2 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/__init__.py +1 -1
- scip_cli-2.1.0/scip_cli/analyze/graph.py +130 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/project.py +6 -38
- scip_cli-2.1.0/tests/perf_util.py +45 -0
- scip_cli-2.1.0/tests/test_analyze_graph.py +30 -0
- scip_cli-2.1.0/tests/test_analyze_perf.py +39 -0
- scip_cli-2.1.0/tests/test_e2e_perf.py +53 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/.gitignore +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/.pre-commit-config.yaml +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/LICENSE +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/pyproject.toml +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/__main__.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/__init__.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/common.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/file.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/sections.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/symbol.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/targets.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/cache.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/cli_args.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/__init__.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/analyze.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/code.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/members.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/rdeps.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/refs.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/reindex.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/search.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/skill.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/symbols.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/config.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/debug.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/discover.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/indexing.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/merge.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/output.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/paths.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/project.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/queries.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/scip_tool.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/scope.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/session.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/source.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/sql.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/symbols.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/targets.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scripts/build.sh +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scripts/publish.sh +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/scripts/test.sh +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/__init__.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/analyze_db.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/conftest.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/e2e_harness.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixture_catalog.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/package.json +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/app/handler.ts +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/config.ts +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/consumer.ts +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/dead.ts +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/helper.ts +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/index.ts +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/user.ts +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/widget.ts +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/tsconfig.json +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_analyze.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_cache.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_composability.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_config.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_discover.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_e2e.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_index_prune.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_indexer_env.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_merge.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_multi_symbol.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_pure_functions.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_qualified_symbols.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_reindex.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_scip_tool.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_scope.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_targets.py +0 -0
- {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_typescript_projects.py +0 -0
|
@@ -7,26 +7,32 @@ alwaysApply: true
|
|
|
7
7
|
|
|
8
8
|
**Work from repo root.** CLI usage, flags, and user gotchas: `scip-cli skill` (source: `scip_cli/SKILL.md`). Read once per task — do not duplicate SKILL here.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
**Almost always:** bare `scip-cli` on PATH must be **this checkout live** (`pip install -e ".[dev]"`). All examples use `scip-cli` — never `.venv/bin/scip-cli`, `python -m scip_cli`, or other paths. Only swap to PyPI briefly to smoke-test a release.
|
|
11
11
|
|
|
12
12
|
## Bootstrap
|
|
13
13
|
|
|
14
|
+
From repo root — keep this mapping for the whole dev session:
|
|
15
|
+
|
|
14
16
|
```bash
|
|
15
|
-
python -m venv .venv && source .venv/bin/activate
|
|
16
17
|
pip install -e ".[dev]"
|
|
18
|
+
scip-cli --version # must match scip_cli/__init__.py
|
|
17
19
|
pre-commit install
|
|
18
|
-
scip-cli skill
|
|
20
|
+
scip-cli skill ~/.claude/skills/scip-cli/
|
|
19
21
|
```
|
|
20
22
|
|
|
23
|
+
After publishing: `pip install scip-cli` → smoke-test → `pip install -e ".[dev]"` to restore local.
|
|
24
|
+
|
|
25
|
+
Optional venv for isolation only: create/activate, then same `pip install -e ".[dev]"` — `scip-cli` still runs from PATH, not a venv-relative path.
|
|
26
|
+
|
|
21
27
|
Node.js + `npx` required for integration tests (`scip-typescript`). Optional: `SCIP_CLI_DEBUG=1` (SQL to stderr, truncated).
|
|
22
28
|
|
|
23
29
|
## Lint / test
|
|
24
30
|
|
|
25
31
|
| Task | Command |
|
|
26
32
|
|------|---------|
|
|
27
|
-
| Full suite |
|
|
28
|
-
| E2e loop |
|
|
29
|
-
| Lint / format |
|
|
33
|
+
| Full suite | `pytest` |
|
|
34
|
+
| E2e loop | `pytest tests/test_e2e.py` |
|
|
35
|
+
| Lint / format | `ruff check .` / `ruff format .` |
|
|
30
36
|
|
|
31
37
|
Pre-commit: ruff + ruff-format + full pytest. E2e before commit on command changes.
|
|
32
38
|
|
|
@@ -35,11 +41,11 @@ Pre-commit: ruff + ruff-format + full pytest. E2e before commit on command chang
|
|
|
35
41
|
**Dogfood on the repo itself** — faster and more honest than spawning reviewers:
|
|
36
42
|
|
|
37
43
|
```bash
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
scip-cli reindex
|
|
45
|
+
scip-cli analyze --limit 25
|
|
46
|
+
scip-cli analyze --priority high --limit 25 # dead code & cycles only
|
|
47
|
+
scip-cli analyze scip_cli --limit 15 # directory
|
|
48
|
+
scip-cli analyze scip_cli/queries.py --limit 20 # file (+ scoped project)
|
|
43
49
|
```
|
|
44
50
|
|
|
45
51
|
Interpretation: README § *Finding easy wins with analyze*. Target = omit (project), directory, file, or symbol — not `--path` for scope.
|
|
@@ -103,6 +109,32 @@ SQLite has **line ranges** (`defn_enclosing_ranges`) so LOC is cheap. That does
|
|
|
103
109
|
|
|
104
110
|
**Rule:** size-only checks stay **low or file-drill-down**; never high/medium unless paired with a **structural smell** (cycle, dead export, zero production consumers). Prefer enriching existing low-tier lines (e.g. show `loc` on bottlenecks) over a new dashboard section.
|
|
105
111
|
|
|
112
|
+
## SQL / analyze benchmarks
|
|
113
|
+
|
|
114
|
+
The e2e fixture and this repo's own index are **small** — every query looks fast here. That hides blowups on real projects (40MB+ indexes, dense graphs).
|
|
115
|
+
|
|
116
|
+
**Before merging new or heavily changed SQL** (`queries.py`, `analyze/*`):
|
|
117
|
+
|
|
118
|
+
1. Time it against **1–2 existing checks** on a **large cached index** you already have (not the fixture):
|
|
119
|
+
```bash
|
|
120
|
+
python3 -u -c "
|
|
121
|
+
import sqlite3, time, sys
|
|
122
|
+
from pathlib import Path
|
|
123
|
+
sys.path.insert(0, '.')
|
|
124
|
+
from scip_cli.analyze import project as p
|
|
125
|
+
from scip_cli.sql import configure_read_connection
|
|
126
|
+
db_path = Path.home() / '.cache/scip-cli/projects/<project-slug>/index.db'
|
|
127
|
+
db = sqlite3.connect(f'file:{db_path}?mode=ro', uri=True)
|
|
128
|
+
configure_read_connection(db)
|
|
129
|
+
for name, fn in [('hotspots', p.hotspots), ('cycles', p.cycles), ('dead_exports', p.dead_exports)]:
|
|
130
|
+
t = time.perf_counter(); fn(db, limit=25); print(f'{name}: {(time.perf_counter()-t)*1000:.0f}ms')
|
|
131
|
+
"
|
|
132
|
+
```
|
|
133
|
+
2. Flag anything **notably slower** than peers — orders of magnitude or multi-second, not a few ms drift.
|
|
134
|
+
3. Swap in your new/changed check for one of the rows; keep a fast peer (e.g. `hotspots`) and a heavy peer (e.g. `dead_exports`).
|
|
135
|
+
|
|
136
|
+
**In CI:** `tests/test_e2e_perf.py` (whole commands) and `tests/test_analyze_perf.py` (per-check on mini DB) catch gross regressions on the tiny fixture only — they **will not** catch large-repo OOMs. Use the manual benchmark above for that.
|
|
137
|
+
|
|
106
138
|
## TDD
|
|
107
139
|
|
|
108
140
|
1. **Red** — `tests/test_e2e.py` + `fixture_catalog` constants.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scip-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Fast code intelligence via SCIP indexes
|
|
5
5
|
Project-URL: Homepage, https://github.com/flesler/scip-cli
|
|
6
6
|
Author: Ariel Flesler
|
|
@@ -62,17 +62,24 @@ scip-cli skill
|
|
|
62
62
|
pip install scip-cli
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
**Local development**
|
|
65
|
+
**Local development** — almost always keep bare `scip-cli` mapped to this checkout via editable install. You edit the repo; the CLI you run is live code, not a frozen PyPI copy.
|
|
66
66
|
|
|
67
67
|
```bash
|
|
68
68
|
cd scip-cli
|
|
69
|
-
python -m venv .venv
|
|
70
|
-
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
71
69
|
pip install -e ".[dev]"
|
|
72
70
|
scip-cli --version
|
|
73
71
|
```
|
|
74
72
|
|
|
75
|
-
|
|
73
|
+
All docs and examples use `scip-cli` (never `.venv/bin/scip-cli` or `python -m scip_cli`). After publishing, briefly `pip install scip-cli` to smoke-test PyPI, then `pip install -e ".[dev]"` again.
|
|
74
|
+
|
|
75
|
+
Optional venv for isolation — activate it, run the same editable install, still invoke `scip-cli` on PATH:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python -m venv .venv && source .venv/bin/activate
|
|
79
|
+
pip install -e ".[dev]"
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Tests and lint: `pytest`, `ruff check .` from the same environment.
|
|
76
83
|
|
|
77
84
|
### 2. Install prerequisites (optional)
|
|
78
85
|
|
|
@@ -42,17 +42,24 @@ scip-cli skill
|
|
|
42
42
|
pip install scip-cli
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
**Local development**
|
|
45
|
+
**Local development** — almost always keep bare `scip-cli` mapped to this checkout via editable install. You edit the repo; the CLI you run is live code, not a frozen PyPI copy.
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
cd scip-cli
|
|
49
|
-
python -m venv .venv
|
|
50
|
-
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
51
49
|
pip install -e ".[dev]"
|
|
52
50
|
scip-cli --version
|
|
53
51
|
```
|
|
54
52
|
|
|
55
|
-
|
|
53
|
+
All docs and examples use `scip-cli` (never `.venv/bin/scip-cli` or `python -m scip_cli`). After publishing, briefly `pip install scip-cli` to smoke-test PyPI, then `pip install -e ".[dev]"` again.
|
|
54
|
+
|
|
55
|
+
Optional venv for isolation — activate it, run the same editable install, still invoke `scip-cli` on PATH:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python -m venv .venv && source .venv/bin/activate
|
|
59
|
+
pip install -e ".[dev]"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Tests and lint: `pytest`, `ruff check .` from the same environment.
|
|
56
63
|
|
|
57
64
|
### 2. Install prerequisites (optional)
|
|
58
65
|
|
|
@@ -7,6 +7,8 @@ TypeScript/JavaScript (.ts, .tsx, .js, .jsx) and Python (.py) — not GraphQL, C
|
|
|
7
7
|
|
|
8
8
|
All commands are sub-commands of `scip-cli`. Run from the project root.
|
|
9
9
|
|
|
10
|
+
**Contributors:** keep `pip install -e .` (or `pip install -e ".[dev]"` in this repo) so bare `scip-cli` on PATH runs your live checkout — not a venv-relative path.
|
|
11
|
+
|
|
10
12
|
## Quick Decision Guide
|
|
11
13
|
|
|
12
14
|
| Question | Use | What you get |
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""File dependency graph helpers for analyze (cycle detection)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
|
|
7
|
+
from .common import fetch_all
|
|
8
|
+
|
|
9
|
+
FILE_EDGES_SQL = """
|
|
10
|
+
SELECT DISTINCT d1.relative_path AS from_file, d2.relative_path AS to_file
|
|
11
|
+
FROM mentions m
|
|
12
|
+
JOIN chunks c ON m.chunk_id = c.id
|
|
13
|
+
JOIN documents d1 ON c.document_id = d1.id
|
|
14
|
+
JOIN defn_enclosing_ranges der ON m.symbol_id = der.symbol_id
|
|
15
|
+
JOIN documents d2 ON der.document_id = d2.id
|
|
16
|
+
WHERE d1.id != d2.id AND m.role != 1
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fetch_file_edges(db) -> list[tuple[str, str]]:
|
|
21
|
+
return fetch_all(db, FILE_EDGES_SQL)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _tarjan_sccs(graph: dict[str, list[str]], nodes: set[str]) -> list[list[str]]:
|
|
25
|
+
index = 0
|
|
26
|
+
stack: list[str] = []
|
|
27
|
+
on_stack: set[str] = set()
|
|
28
|
+
indices: dict[str, int] = {}
|
|
29
|
+
lowlink: dict[str, int] = {}
|
|
30
|
+
sccs: list[list[str]] = []
|
|
31
|
+
|
|
32
|
+
def strongconnect(vertex: str) -> None:
|
|
33
|
+
nonlocal index
|
|
34
|
+
indices[vertex] = index
|
|
35
|
+
lowlink[vertex] = index
|
|
36
|
+
index += 1
|
|
37
|
+
stack.append(vertex)
|
|
38
|
+
on_stack.add(vertex)
|
|
39
|
+
for neighbor in graph.get(vertex, ()):
|
|
40
|
+
if neighbor not in indices:
|
|
41
|
+
strongconnect(neighbor)
|
|
42
|
+
lowlink[vertex] = min(lowlink[vertex], lowlink[neighbor])
|
|
43
|
+
elif neighbor in on_stack:
|
|
44
|
+
lowlink[vertex] = min(lowlink[vertex], indices[neighbor])
|
|
45
|
+
if lowlink[vertex] == indices[vertex]:
|
|
46
|
+
component: list[str] = []
|
|
47
|
+
while True:
|
|
48
|
+
w = stack.pop()
|
|
49
|
+
on_stack.remove(w)
|
|
50
|
+
component.append(w)
|
|
51
|
+
if w == vertex:
|
|
52
|
+
break
|
|
53
|
+
sccs.append(component)
|
|
54
|
+
|
|
55
|
+
for node in nodes:
|
|
56
|
+
if node not in indices:
|
|
57
|
+
strongconnect(node)
|
|
58
|
+
return sccs
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cycles_in_scc(
|
|
62
|
+
graph: dict[str, list[str]],
|
|
63
|
+
scc_nodes: list[str],
|
|
64
|
+
*,
|
|
65
|
+
max_depth: int,
|
|
66
|
+
limit: int,
|
|
67
|
+
) -> list[str]:
|
|
68
|
+
if len(scc_nodes) <= 2:
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
scc = set(scc_nodes)
|
|
72
|
+
subgraph: dict[str, list[str]] = defaultdict(list)
|
|
73
|
+
for src in scc:
|
|
74
|
+
for dst in graph.get(src, ()):
|
|
75
|
+
if dst in scc:
|
|
76
|
+
subgraph[src].append(dst)
|
|
77
|
+
|
|
78
|
+
found: dict[tuple[str, ...], str] = {}
|
|
79
|
+
|
|
80
|
+
def record(path: list[str]) -> None:
|
|
81
|
+
key = min(tuple(path[i:] + path[:i]) for i in range(len(path)))
|
|
82
|
+
if key not in found:
|
|
83
|
+
found[key] = " -> ".join([*path, path[0]])
|
|
84
|
+
|
|
85
|
+
for origin in sorted(scc):
|
|
86
|
+
stack: list[tuple[str, list[str]]] = [(origin, [origin])]
|
|
87
|
+
while stack:
|
|
88
|
+
node, path = stack.pop()
|
|
89
|
+
if len(path) > max_depth:
|
|
90
|
+
continue
|
|
91
|
+
for nxt in subgraph.get(node, ()):
|
|
92
|
+
if nxt == origin and len(path) >= 2:
|
|
93
|
+
record(path)
|
|
94
|
+
if len(found) >= limit:
|
|
95
|
+
return sorted(found.values())[:limit]
|
|
96
|
+
elif nxt not in path:
|
|
97
|
+
stack.append((nxt, [*path, nxt]))
|
|
98
|
+
return sorted(found.values())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def find_longer_cycles(
|
|
102
|
+
edges: list[tuple[str, str]],
|
|
103
|
+
*,
|
|
104
|
+
max_depth: int = 8,
|
|
105
|
+
limit: int,
|
|
106
|
+
) -> list[str]:
|
|
107
|
+
"""Find directed cycles with 3+ distinct files (2-node pairs use the SQL two-way check)."""
|
|
108
|
+
if limit <= 0 or not edges:
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
graph: dict[str, list[str]] = defaultdict(list)
|
|
112
|
+
nodes: set[str] = set()
|
|
113
|
+
for src, dst in edges:
|
|
114
|
+
graph[src].append(dst)
|
|
115
|
+
nodes.add(src)
|
|
116
|
+
nodes.add(dst)
|
|
117
|
+
|
|
118
|
+
found: dict[tuple[str, ...], str] = {}
|
|
119
|
+
for component in _tarjan_sccs(graph, nodes):
|
|
120
|
+
if len(component) <= 2:
|
|
121
|
+
continue
|
|
122
|
+
for line in _cycles_in_scc(graph, component, max_depth=max_depth, limit=limit):
|
|
123
|
+
path = line.split(" -> ")
|
|
124
|
+
body = path[:-1]
|
|
125
|
+
key = min(tuple(body[i:] + body[:i]) for i in range(len(body)))
|
|
126
|
+
if key not in found:
|
|
127
|
+
found[key] = line
|
|
128
|
+
if len(found) >= limit:
|
|
129
|
+
return sorted(found.values())[:limit]
|
|
130
|
+
return sorted(found.values())[:limit]
|
|
@@ -16,18 +16,9 @@ from .common import (
|
|
|
16
16
|
short_name,
|
|
17
17
|
stale_type_noise,
|
|
18
18
|
)
|
|
19
|
+
from .graph import FILE_EDGES_SQL, fetch_file_edges, find_longer_cycles
|
|
19
20
|
from .sections import Check, Priority, run_checks
|
|
20
21
|
|
|
21
|
-
_FILE_EDGES_SQL = """
|
|
22
|
-
SELECT DISTINCT d1.relative_path AS from_file, d2.relative_path AS to_file
|
|
23
|
-
FROM mentions m
|
|
24
|
-
JOIN chunks c ON m.chunk_id = c.id
|
|
25
|
-
JOIN documents d1 ON c.document_id = d1.id
|
|
26
|
-
JOIN defn_enclosing_ranges der ON m.symbol_id = der.symbol_id
|
|
27
|
-
JOIN documents d2 ON der.document_id = d2.id
|
|
28
|
-
WHERE d1.id != d2.id AND m.role != 1
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
22
|
|
|
32
23
|
def _scope_suffix(scope: str | None) -> str:
|
|
33
24
|
return f" [{scope}]" if scope else ""
|
|
@@ -134,34 +125,11 @@ def cycles(
|
|
|
134
125
|
include_tests: bool = False,
|
|
135
126
|
scope: str | None = None,
|
|
136
127
|
) -> list[str]:
|
|
137
|
-
|
|
138
|
-
db,
|
|
139
|
-
f"""
|
|
140
|
-
WITH RECURSIVE
|
|
141
|
-
edges AS ({_FILE_EDGES_SQL}),
|
|
142
|
-
walk(origin, current, depth, path) AS (
|
|
143
|
-
SELECT from_file, from_file, 0, from_file FROM edges
|
|
144
|
-
UNION ALL
|
|
145
|
-
SELECT walk.origin, e.to_file, walk.depth + 1,
|
|
146
|
-
walk.path || ' -> ' || e.to_file
|
|
147
|
-
FROM walk
|
|
148
|
-
JOIN edges e ON walk.current = e.from_file
|
|
149
|
-
WHERE walk.depth < 8
|
|
150
|
-
AND instr(walk.path, e.to_file) = 0
|
|
151
|
-
)
|
|
152
|
-
SELECT DISTINCT walk.path || ' -> ' || walk.origin AS cycle_path
|
|
153
|
-
FROM walk
|
|
154
|
-
JOIN edges e ON walk.current = e.from_file AND e.to_file = walk.origin
|
|
155
|
-
WHERE walk.depth > 0
|
|
156
|
-
ORDER BY cycle_path
|
|
157
|
-
LIMIT ?
|
|
158
|
-
""",
|
|
159
|
-
(limit * 5,),
|
|
160
|
-
)
|
|
128
|
+
cap = limit * 5
|
|
161
129
|
two_way = fetch_all(
|
|
162
130
|
db,
|
|
163
131
|
f"""
|
|
164
|
-
WITH edges AS ({
|
|
132
|
+
WITH edges AS ({FILE_EDGES_SQL})
|
|
165
133
|
SELECT e1.from_file || ' <-> ' || e1.to_file
|
|
166
134
|
FROM edges e1
|
|
167
135
|
JOIN edges e2 ON e1.from_file = e2.to_file AND e1.to_file = e2.from_file
|
|
@@ -169,11 +137,11 @@ def cycles(
|
|
|
169
137
|
ORDER BY 1
|
|
170
138
|
LIMIT ?
|
|
171
139
|
""",
|
|
172
|
-
(
|
|
140
|
+
(cap,),
|
|
173
141
|
)
|
|
174
142
|
lines = [row[0] for row in two_way if not cycle_path_noise(row[0], include_tests=include_tests)]
|
|
175
|
-
|
|
176
|
-
|
|
143
|
+
longer = find_longer_cycles(fetch_file_edges(db), max_depth=8, limit=cap)
|
|
144
|
+
for path in longer:
|
|
177
145
|
if path not in lines and not cycle_path_noise(path, include_tests=include_tests):
|
|
178
146
|
lines.append(path)
|
|
179
147
|
if scope:
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Timing helpers for coarse perf regression guards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import statistics
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
DEFAULT_RUNS = 3
|
|
13
|
+
DEFAULT_WARMUP = 1
|
|
14
|
+
# Ignore sub-millisecond noise; require both ratio and floor before flagging.
|
|
15
|
+
MIN_REPORT_MS = 5.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def median_elapsed(
|
|
19
|
+
fn: Callable[[], T],
|
|
20
|
+
*,
|
|
21
|
+
runs: int = DEFAULT_RUNS,
|
|
22
|
+
warmup: int = DEFAULT_WARMUP,
|
|
23
|
+
) -> float:
|
|
24
|
+
for _ in range(warmup):
|
|
25
|
+
fn()
|
|
26
|
+
samples: list[float] = []
|
|
27
|
+
for _ in range(runs):
|
|
28
|
+
start = time.perf_counter()
|
|
29
|
+
fn()
|
|
30
|
+
samples.append(time.perf_counter() - start)
|
|
31
|
+
return statistics.median(samples)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def outliers_vs_median(
|
|
35
|
+
timings: dict[str, float],
|
|
36
|
+
*,
|
|
37
|
+
ratio: float,
|
|
38
|
+
floor_s: float = MIN_REPORT_MS / 1000.0,
|
|
39
|
+
) -> dict[str, float]:
|
|
40
|
+
"""Names where timing exceeds max(ratio x peer median, floor_s)."""
|
|
41
|
+
if not timings:
|
|
42
|
+
return {}
|
|
43
|
+
peer = statistics.median(timings.values())
|
|
44
|
+
threshold = max(peer * ratio, floor_s)
|
|
45
|
+
return {name: elapsed for name, elapsed in timings.items() if elapsed > threshold}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Tests for analyze graph helpers."""
|
|
2
|
+
|
|
3
|
+
from scip_cli.analyze.graph import find_longer_cycles
|
|
4
|
+
|
|
5
|
+
from .analyze_db import AnalyzeDbBuilder
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestFindLongerCycles:
|
|
9
|
+
def test_three_node_cycle(self):
|
|
10
|
+
edges = [("a.ts", "b.ts"), ("b.ts", "c.ts"), ("c.ts", "a.ts")]
|
|
11
|
+
lines = find_longer_cycles(edges, max_depth=8, limit=10)
|
|
12
|
+
assert len(lines) == 1
|
|
13
|
+
assert "a.ts" in lines[0]
|
|
14
|
+
assert "b.ts" in lines[0]
|
|
15
|
+
assert "c.ts" in lines[0]
|
|
16
|
+
|
|
17
|
+
def test_two_node_skipped(self):
|
|
18
|
+
edges = [("a.ts", "b.ts"), ("b.ts", "a.ts")]
|
|
19
|
+
assert find_longer_cycles(edges, max_depth=8, limit=10) == []
|
|
20
|
+
|
|
21
|
+
def test_mini_codebase_no_long_cycles(self):
|
|
22
|
+
b = AnalyzeDbBuilder()
|
|
23
|
+
sym_x = b.define("src/cycle/a.ts", "alpha")
|
|
24
|
+
sym_y = b.define("src/cycle/b.ts", "beta")
|
|
25
|
+
b.reference("src/cycle/a.ts", sym_y)
|
|
26
|
+
b.reference("src/cycle/b.ts", sym_x)
|
|
27
|
+
from scip_cli.analyze.graph import fetch_file_edges
|
|
28
|
+
|
|
29
|
+
edges = fetch_file_edges(b.finish())
|
|
30
|
+
assert find_longer_cycles(edges, max_depth=8, limit=10) == []
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Relative timing guard for project analyze checks on the in-memory mini DB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from scip_cli.analyze import project as project_checks
|
|
6
|
+
from tests.analyze_db import mini_codebase_db
|
|
7
|
+
from tests.perf_util import median_elapsed, outliers_vs_median
|
|
8
|
+
|
|
9
|
+
# Mini DB is tiny — generous ratio; still catches runaway SQL (e.g. recursive blowups).
|
|
10
|
+
CHECK_RATIO = 25.0
|
|
11
|
+
BASELINE_CHECKS = ("hotspots", "coupling", "stale_types", "dead_exports")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestAnalyzeCheckPerf:
|
|
15
|
+
def test_project_checks_not_dramatically_slower_than_peers(self):
|
|
16
|
+
db = mini_codebase_db()
|
|
17
|
+
checks = {
|
|
18
|
+
"hotspots": project_checks.hotspots,
|
|
19
|
+
"coupling": project_checks.top_coupling,
|
|
20
|
+
"stale_types": project_checks.stale_types,
|
|
21
|
+
"cycles": project_checks.cycles,
|
|
22
|
+
"bottlenecks": project_checks.bottlenecks,
|
|
23
|
+
"dead_exports": project_checks.dead_exports,
|
|
24
|
+
"unreferenced": project_checks.unreferenced_symbols,
|
|
25
|
+
"same_file": project_checks.same_file_only,
|
|
26
|
+
"test_only": project_checks.symbols_test_only_consumers,
|
|
27
|
+
}
|
|
28
|
+
timings = {
|
|
29
|
+
name: median_elapsed(lambda fn=fn: fn(db, limit=20), runs=5, warmup=2) for name, fn in checks.items()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
baseline = {k: timings[k] for k in BASELINE_CHECKS if k in timings}
|
|
33
|
+
slow = outliers_vs_median(timings, ratio=CHECK_RATIO)
|
|
34
|
+
baseline_slow = {k: v for k, v in slow.items() if k not in baseline}
|
|
35
|
+
assert not baseline_slow, (
|
|
36
|
+
f"Analyze check(s) >{CHECK_RATIO:.0f}x mini-DB peer median: "
|
|
37
|
+
+ ", ".join(f"{k}={v * 1000:.2f}ms" for k, v in sorted(baseline_slow.items()))
|
|
38
|
+
+ f" (peer median {sorted(baseline.values())[len(baseline) // 2] * 1000:.2f}ms)"
|
|
39
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Coarse e2e timing guard — fixture is tiny; catches gross CLI regressions only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from tests.e2e_harness import CliRunner
|
|
8
|
+
from tests.fixture_catalog import CLASS_WIDGET, FN_GREET, HELPER_FILE
|
|
9
|
+
from tests.perf_util import median_elapsed, outliers_vs_median
|
|
10
|
+
|
|
11
|
+
pytestmark = pytest.mark.integration
|
|
12
|
+
|
|
13
|
+
# Whole-command smoke: nothing should be orders of magnitude slower than peers.
|
|
14
|
+
FAST_RATIO = 5.0
|
|
15
|
+
ANALYZE_FULL_VS_HIGH_RATIO = 6.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _time_cli(cli: CliRunner, *argv: str) -> float:
|
|
19
|
+
return median_elapsed(lambda: cli.run(*argv))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestE2eCommandPerf:
|
|
23
|
+
def test_command_timings_no_dramatic_outliers(self, cli):
|
|
24
|
+
"""On the small fixture all commands finish in a few ms — flag huge skew only."""
|
|
25
|
+
fast = {
|
|
26
|
+
"search": _time_cli(cli, "search", FN_GREET, "--limit", "3"),
|
|
27
|
+
"symbols": _time_cli(cli, "symbols", HELPER_FILE, "--limit", "10"),
|
|
28
|
+
"code": _time_cli(cli, "code", FN_GREET, "--limit", "1"),
|
|
29
|
+
"refs": _time_cli(cli, "refs", FN_GREET, "--paths-only", "--limit", "10"),
|
|
30
|
+
"members": _time_cli(cli, "members", CLASS_WIDGET, "--names-only"),
|
|
31
|
+
"rdeps": _time_cli(cli, "rdeps", HELPER_FILE, "--limit", "10"),
|
|
32
|
+
}
|
|
33
|
+
analyze_high = _time_cli(cli, "analyze", "--priority", "high", "--limit", "5")
|
|
34
|
+
analyze_all = _time_cli(cli, "analyze", "--limit", "5")
|
|
35
|
+
|
|
36
|
+
fast_outliers = outliers_vs_median(fast, ratio=FAST_RATIO)
|
|
37
|
+
assert not fast_outliers, f"Fast commands skewed vs peers on fixture (>{FAST_RATIO:.0f}x median): " + ", ".join(
|
|
38
|
+
f"{k}={v * 1000:.1f}ms" for k, v in sorted(fast_outliers.items())
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
fast_peer = sorted(fast.values())[len(fast) // 2]
|
|
42
|
+
analyze_high_limit = max(fast_peer * FAST_RATIO, analyze_high)
|
|
43
|
+
assert analyze_high <= analyze_high_limit, (
|
|
44
|
+
f"analyze --priority high too slow on fixture: {analyze_high * 1000:.1f}ms "
|
|
45
|
+
f"(limit {analyze_high_limit * 1000:.1f}ms)"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
analyze_all_limit = max(analyze_high * ANALYZE_FULL_VS_HIGH_RATIO, analyze_high)
|
|
49
|
+
assert analyze_all <= analyze_all_limit, (
|
|
50
|
+
f"analyze (all priorities) too slow vs high-only on fixture: "
|
|
51
|
+
f"{analyze_all * 1000:.1f}ms vs high {analyze_high * 1000:.1f}ms "
|
|
52
|
+
f"(limit {analyze_all_limit * 1000:.1f}ms)"
|
|
53
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|