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.
Files changed (85) hide show
  1. {scip_cli-2.0.0 → scip_cli-2.1.0}/.cursor/rules/agent.mdc +43 -11
  2. {scip_cli-2.0.0 → scip_cli-2.1.0}/PKG-INFO +12 -5
  3. {scip_cli-2.0.0 → scip_cli-2.1.0}/README.md +11 -4
  4. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/SKILL.md +2 -0
  5. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/__init__.py +1 -1
  6. scip_cli-2.1.0/scip_cli/analyze/graph.py +130 -0
  7. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/project.py +6 -38
  8. scip_cli-2.1.0/tests/perf_util.py +45 -0
  9. scip_cli-2.1.0/tests/test_analyze_graph.py +30 -0
  10. scip_cli-2.1.0/tests/test_analyze_perf.py +39 -0
  11. scip_cli-2.1.0/tests/test_e2e_perf.py +53 -0
  12. {scip_cli-2.0.0 → scip_cli-2.1.0}/.gitignore +0 -0
  13. {scip_cli-2.0.0 → scip_cli-2.1.0}/.pre-commit-config.yaml +0 -0
  14. {scip_cli-2.0.0 → scip_cli-2.1.0}/LICENSE +0 -0
  15. {scip_cli-2.0.0 → scip_cli-2.1.0}/pyproject.toml +0 -0
  16. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/__main__.py +0 -0
  17. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/__init__.py +0 -0
  18. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/common.py +0 -0
  19. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/file.py +0 -0
  20. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/sections.py +0 -0
  21. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/symbol.py +0 -0
  22. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/analyze/targets.py +0 -0
  23. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/cache.py +0 -0
  24. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/cli_args.py +0 -0
  25. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/__init__.py +0 -0
  26. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/analyze.py +0 -0
  27. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/code.py +0 -0
  28. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/members.py +0 -0
  29. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/rdeps.py +0 -0
  30. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/refs.py +0 -0
  31. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/reindex.py +0 -0
  32. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/search.py +0 -0
  33. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/skill.py +0 -0
  34. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/commands/symbols.py +0 -0
  35. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/config.py +0 -0
  36. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/debug.py +0 -0
  37. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/discover.py +0 -0
  38. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/indexing.py +0 -0
  39. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/merge.py +0 -0
  40. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/output.py +0 -0
  41. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/paths.py +0 -0
  42. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/project.py +0 -0
  43. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/queries.py +0 -0
  44. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/scip_tool.py +0 -0
  45. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/scope.py +0 -0
  46. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/session.py +0 -0
  47. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/source.py +0 -0
  48. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/sql.py +0 -0
  49. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/symbols.py +0 -0
  50. {scip_cli-2.0.0 → scip_cli-2.1.0}/scip_cli/targets.py +0 -0
  51. {scip_cli-2.0.0 → scip_cli-2.1.0}/scripts/build.sh +0 -0
  52. {scip_cli-2.0.0 → scip_cli-2.1.0}/scripts/publish.sh +0 -0
  53. {scip_cli-2.0.0 → scip_cli-2.1.0}/scripts/test.sh +0 -0
  54. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/__init__.py +0 -0
  55. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/analyze_db.py +0 -0
  56. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/conftest.py +0 -0
  57. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/e2e_harness.py +0 -0
  58. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixture_catalog.py +0 -0
  59. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/package.json +0 -0
  60. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/app/handler.ts +0 -0
  61. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/config.ts +0 -0
  62. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/consumer.ts +0 -0
  63. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/dead.ts +0 -0
  64. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/helper.ts +0 -0
  65. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/index.ts +0 -0
  66. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/user.ts +0 -0
  67. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/src/widget.ts +0 -0
  68. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/fixtures/sample-project/tsconfig.json +0 -0
  69. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_analyze.py +0 -0
  70. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_cache.py +0 -0
  71. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_composability.py +0 -0
  72. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_config.py +0 -0
  73. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_discover.py +0 -0
  74. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_e2e.py +0 -0
  75. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_index_prune.py +0 -0
  76. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_indexer_env.py +0 -0
  77. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_merge.py +0 -0
  78. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_multi_symbol.py +0 -0
  79. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_pure_functions.py +0 -0
  80. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_qualified_symbols.py +0 -0
  81. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_reindex.py +0 -0
  82. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_scip_tool.py +0 -0
  83. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_scope.py +0 -0
  84. {scip_cli-2.0.0 → scip_cli-2.1.0}/tests/test_targets.py +0 -0
  85. {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
- Use **`.venv/bin/scip-cli`** / **`.venv/bin/pytest`**. Uncommitted code: `PYTHONPATH=$PWD python -m scip_cli …` if the venv binary is stale.
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 | `.venv/bin/pytest` |
28
- | E2e loop | `.venv/bin/pytest tests/test_e2e.py` |
29
- | Lint / format | `.venv/bin/ruff check .` / `ruff 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
- .venv/bin/scip-cli reindex
39
- .venv/bin/scip-cli analyze --limit 25
40
- .venv/bin/scip-cli analyze --priority high --limit 25 # dead code & cycles only
41
- .venv/bin/scip-cli analyze scip_cli --limit 15 # directory
42
- .venv/bin/scip-cli analyze scip_cli/queries.py --limit 20 # file (+ scoped project)
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.0.0
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** (use a project venv do not rely on global `pip`):
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
- `pip install -e .` keeps `scip-cli` on your PATH inside the venv while you edit the repo. Run tests with `pytest` from the same venv.
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** (use a project venv do not rely on global `pip`):
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
- `pip install -e .` keeps `scip-cli` on your PATH inside the venv while you edit the repo. Run tests with `pytest` from the same venv.
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 |
@@ -1,3 +1,3 @@
1
1
  """scip-cli: Fast code intelligence via SCIP indexes."""
2
2
 
3
- __version__ = "2.0.0"
3
+ __version__ = "2.1.0"
@@ -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
- rows = fetch_all(
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 ({_FILE_EDGES_SQL})
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
- (limit * 5,),
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
- for row in rows:
176
- path = row[0]
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