scip-cli 2.0.0__tar.gz → 2.2.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 (110) hide show
  1. {scip_cli-2.0.0 → scip_cli-2.2.0}/.cursor/rules/agent.mdc +75 -20
  2. {scip_cli-2.0.0 → scip_cli-2.2.0}/PKG-INFO +12 -5
  3. {scip_cli-2.0.0 → scip_cli-2.2.0}/README.md +11 -4
  4. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/SKILL.md +2 -0
  5. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/__init__.py +1 -1
  6. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/analyze/common.py +26 -3
  7. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/analyze/file.py +53 -17
  8. scip_cli-2.2.0/scip_cli/analyze/graph.py +133 -0
  9. scip_cli-2.2.0/scip_cli/analyze/live.py +139 -0
  10. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/analyze/project.py +109 -66
  11. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/commands/analyze.py +3 -0
  12. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/commands/refs.py +4 -2
  13. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/output.py +17 -0
  14. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/queries.py +21 -0
  15. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/symbols.py +26 -0
  16. scip_cli-2.2.0/scripts/publish.sh +96 -0
  17. scip_cli-2.2.0/scripts/verify_rovetia_reports.py +128 -0
  18. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/analyze_db.py +63 -0
  19. scip_cli-2.2.0/tests/fixture_catalog.py +44 -0
  20. scip_cli-2.2.0/tests/fixtures/sample-project/src/domain/i18n/en.ts +3 -0
  21. scip_cli-2.2.0/tests/fixtures/sample-project/src/domain/i18n/index.ts +3 -0
  22. scip_cli-2.2.0/tests/fixtures/sample-project/src/domain/i18n/useLocale.ts +5 -0
  23. scip_cli-2.2.0/tests/fixtures/sample-project/src/domain/labels/index.ts +3 -0
  24. scip_cli-2.2.0/tests/fixtures/sample-project/src/events/streamTypes.ts +7 -0
  25. scip_cli-2.2.0/tests/fixtures/sample-project/src/gateway/index.ts +3 -0
  26. scip_cli-2.2.0/tests/fixtures/sample-project/src/hooks/useHookA.ts +7 -0
  27. scip_cli-2.2.0/tests/fixtures/sample-project/src/hooks/useHookB.ts +7 -0
  28. scip_cli-2.2.0/tests/fixtures/sample-project/src/hooks/useItems.ts +5 -0
  29. scip_cli-2.2.0/tests/fixtures/sample-project/src/integrations/inferenceClient.ts +7 -0
  30. scip_cli-2.2.0/tests/fixtures/sample-project/src/loaders/useInference.ts +5 -0
  31. scip_cli-2.2.0/tests/fixtures/sample-project/src/pages/panelPage.ts +4 -0
  32. scip_cli-2.2.0/tests/fixtures/sample-project/src/rules/applyRule.ts +5 -0
  33. scip_cli-2.2.0/tests/fixtures/sample-project/src/types/a.ts +3 -0
  34. scip_cli-2.2.0/tests/fixtures/sample-project/src/types/b.ts +3 -0
  35. scip_cli-2.2.0/tests/fixtures/sample-project/src/ui/Button.ts +7 -0
  36. scip_cli-2.2.0/tests/fixtures/sample-project/src/ui/LazyPanel.ts +3 -0
  37. scip_cli-2.2.0/tests/fixtures/sample-project/src/ui/buttonConsumer.ts +5 -0
  38. scip_cli-2.2.0/tests/fixtures/sample-project/src/ui/menuModule.ts +1 -0
  39. scip_cli-2.2.0/tests/fixtures/sample-project/src/widgets/OrphanWidget.ts +3 -0
  40. scip_cli-2.2.0/tests/perf_util.py +45 -0
  41. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_analyze.py +38 -4
  42. scip_cli-2.2.0/tests/test_analyze_graph.py +66 -0
  43. scip_cli-2.2.0/tests/test_analyze_perf.py +39 -0
  44. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_e2e.py +2 -2
  45. scip_cli-2.2.0/tests/test_e2e_analyze_patterns.py +176 -0
  46. scip_cli-2.2.0/tests/test_e2e_perf.py +53 -0
  47. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_pure_functions.py +9 -1
  48. scip_cli-2.0.0/scripts/publish.sh +0 -32
  49. scip_cli-2.0.0/tests/fixture_catalog.py +0 -17
  50. {scip_cli-2.0.0 → scip_cli-2.2.0}/.gitignore +0 -0
  51. {scip_cli-2.0.0 → scip_cli-2.2.0}/.pre-commit-config.yaml +0 -0
  52. {scip_cli-2.0.0 → scip_cli-2.2.0}/LICENSE +0 -0
  53. {scip_cli-2.0.0 → scip_cli-2.2.0}/pyproject.toml +0 -0
  54. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/__main__.py +0 -0
  55. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/analyze/__init__.py +0 -0
  56. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/analyze/sections.py +0 -0
  57. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/analyze/symbol.py +0 -0
  58. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/analyze/targets.py +0 -0
  59. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/cache.py +0 -0
  60. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/cli_args.py +0 -0
  61. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/commands/__init__.py +0 -0
  62. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/commands/code.py +0 -0
  63. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/commands/members.py +0 -0
  64. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/commands/rdeps.py +0 -0
  65. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/commands/reindex.py +0 -0
  66. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/commands/search.py +0 -0
  67. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/commands/skill.py +0 -0
  68. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/commands/symbols.py +0 -0
  69. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/config.py +0 -0
  70. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/debug.py +0 -0
  71. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/discover.py +0 -0
  72. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/indexing.py +0 -0
  73. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/merge.py +0 -0
  74. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/paths.py +0 -0
  75. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/project.py +0 -0
  76. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/scip_tool.py +0 -0
  77. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/scope.py +0 -0
  78. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/session.py +0 -0
  79. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/source.py +0 -0
  80. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/sql.py +0 -0
  81. {scip_cli-2.0.0 → scip_cli-2.2.0}/scip_cli/targets.py +0 -0
  82. {scip_cli-2.0.0 → scip_cli-2.2.0}/scripts/build.sh +0 -0
  83. {scip_cli-2.0.0 → scip_cli-2.2.0}/scripts/test.sh +0 -0
  84. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/__init__.py +0 -0
  85. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/conftest.py +0 -0
  86. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/e2e_harness.py +0 -0
  87. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/package.json +0 -0
  88. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/app/handler.ts +0 -0
  89. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/config.ts +0 -0
  90. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/consumer.ts +0 -0
  91. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/dead.ts +0 -0
  92. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/helper.ts +0 -0
  93. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/index.ts +0 -0
  94. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/user.ts +0 -0
  95. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/widget.ts +0 -0
  96. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/tsconfig.json +0 -0
  97. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_cache.py +0 -0
  98. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_composability.py +0 -0
  99. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_config.py +0 -0
  100. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_discover.py +0 -0
  101. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_index_prune.py +0 -0
  102. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_indexer_env.py +0 -0
  103. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_merge.py +0 -0
  104. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_multi_symbol.py +0 -0
  105. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_qualified_symbols.py +0 -0
  106. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_reindex.py +0 -0
  107. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_scip_tool.py +0 -0
  108. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_scope.py +0 -0
  109. {scip_cli-2.0.0 → scip_cli-2.2.0}/tests/test_targets.py +0 -0
  110. {scip_cli-2.0.0 → scip_cli-2.2.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 tests/test_e2e_analyze_patterns.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.
@@ -63,9 +69,10 @@ When you or a review subagent finds a repo issue — bug, dead code, cycle, risk
63
69
 
64
70
  If **yes** (the signal is in `documents`, `mentions`, `global_symbols`, `defn_enclosing_ranges`):
65
71
 
66
- 1. **Extend `analyze` first** — new or tuned SQL check in `scip_cli/analyze/`, priority in `sections.py`, tests in `tests/test_analyze.py` + `analyze_db.py`.
67
- 2. **Dogfood** — `reindex` confirm the issue appears (or the false positive is filtered).
68
- 3. **Then** fix the production code (or document a known blind spot).
72
+ 1. **Red on the fixture** — minimal generic repro + e2e test (see **TDD External project feedback**). Do not name real customer repos in tests.
73
+ 2. **Extend `analyze`** new or tuned SQL/heuristic in `scip_cli/analyze/`, priority in `sections.py`; unit tests only for graph/SQL plumbing (`analyze_db.py`, `test_analyze_graph.py`).
74
+ 3. **Dogfood** `reindex` on this repo (or re-run fixture e2e) confirm signal appears or false positive is filtered.
75
+ 4. **Then** fix production code in the other repo (or document a known blind spot).
69
76
 
70
77
  Only skip step 1 when the issue is **outside the index** (runtime, CLI UX, docs drift, git, env, concurrency, test harness).
71
78
 
@@ -103,16 +110,64 @@ SQLite has **line ranges** (`defn_enclosing_ranges`) so LOC is cheap. That does
103
110
 
104
111
  **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
112
 
113
+ ## SQL / analyze benchmarks
114
+
115
+ 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).
116
+
117
+ **Before merging new or heavily changed SQL** (`queries.py`, `analyze/*`):
118
+
119
+ 1. Time it against **1–2 existing checks** on a **large cached index** you already have (not the fixture):
120
+ ```bash
121
+ python3 -u -c "
122
+ import sqlite3, time, sys
123
+ from pathlib import Path
124
+ sys.path.insert(0, '.')
125
+ from scip_cli.analyze import project as p
126
+ from scip_cli.sql import configure_read_connection
127
+ db_path = Path.home() / '.cache/scip-cli/projects/<project-slug>/index.db'
128
+ db = sqlite3.connect(f'file:{db_path}?mode=ro', uri=True)
129
+ configure_read_connection(db)
130
+ for name, fn in [('hotspots', p.hotspots), ('cycles', p.cycles), ('dead_exports', p.dead_exports)]:
131
+ t = time.perf_counter(); fn(db, limit=25); print(f'{name}: {(time.perf_counter()-t)*1000:.0f}ms')
132
+ "
133
+ ```
134
+ 2. Flag anything **notably slower** than peers — orders of magnitude or multi-second, not a few ms drift.
135
+ 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`).
136
+
137
+ **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.
138
+
106
139
  ## TDD
107
140
 
108
- 1. **Red** `tests/test_e2e.py` + `fixture_catalog` constants.
109
- 2. **Green** — `scip_cli/commands/*.py`.
110
- 3. **Refactor** — unit tests for pure helpers only.
141
+ ### Default: extend the shared fixture
111
142
 
112
- - **E2e** (`cli` fixture): in-process `main()`, TS project indexed once per session default for behavior.
113
- - **Unit**: `:memory:` SQLite, `tests/analyze_db.py`, merge/index prune tests.
143
+ **One indexed TS project** `tests/fixtures/sample-project/` indexed once per session via `indexed_fixture` (`conftest.py`). Stable names live in `tests/fixture_catalog.py`.
144
+
145
+ 1. **Red** — add a **minimal** `.ts` file (or tweak an existing one) that reproduces the SCIP shape; assert in `tests/test_e2e.py` or `tests/test_e2e_analyze_patterns.py` using `cli` / `open_index_db` + analyze helpers.
146
+ 2. **Green** — fix `scip_cli/` (commands, `analyze/*`, `queries.py`, …).
147
+ 3. **Refactor** — only if a pure helper deserves isolation.
148
+
149
+ - **E2e** (`cli` / `indexed_fixture`): in-process `main()` or direct Python API on the **real** index — default for behavior.
114
150
  - **No subprocess** for CLI tests; no `reindex` in e2e (mutates `~/.cache/scip-cli`).
115
- - New symbols: extend `tests/fixtures/sample-project/` + `fixture_catalog.py`.
151
+ - New symbols/paths: fixture file + `fixture_catalog.py` constant — never hardcode paths in test bodies.
152
+
153
+ ### External project feedback → fixture (mandatory workflow)
154
+
155
+ When dogfooding **another repo** surfaces a bug or false positive (analyze noise, dead-export mistake, cycle, stale type, refs mismatch):
156
+
157
+ 1. **Capture the pattern, not the project** — note the SCIP shape (module `/`, export alias `foo0:`, lazy import, `Props#`, barrel cycle, …). Do **not** copy real repo names, paths, or filenames into tests or docs.
158
+ 2. **Red on the fixture** — smallest TS repro under `tests/fixtures/sample-project/` with generic names (`LazyPanel`, `evictItem`, `OrphanWidget`, …). E2e test proves the bug against **real `scip-typescript`** output.
159
+ 3. **Green** — fix analyze heuristics / SQL / CLI; dogfood on this repo if applicable.
160
+ 4. **Verify** — `pytest tests/test_e2e_analyze_patterns.py` (or full `pytest`); only then trim redundant tests.
161
+
162
+ **Prefer fixture e2e over fake SQLite graphs.** `tests/analyze_db.py` (`AnalyzeDbBuilder`, `:memory:`) is for SQL/dashboard **plumbing** and graph primitives (`test_analyze_graph.py`, `mini_codebase_db`) — not for reproducing production SCIP symbol shapes. Hand-built symbols often diverge from scip-typescript (path format, `0:` aliases, module rows) and can pass while production still breaks.
163
+
164
+ | Use fixture e2e | Use `analyze_db` unit |
165
+ |-----------------|----------------------|
166
+ | False positives / analyze checks from real projects | Tarjan, edge-type SQL, `run_all` section wiring |
167
+ | Any behavior that depends on indexer symbol strings | Noise filters with one or two synthetic rows |
168
+ | CLI commands (`refs`, `search`, `analyze` output) | Merge/index prune tests |
169
+
170
+ If both layers help during development, **keep both until e2e passes**, then delete duplicate in-memory repros — fixture is the source of truth.
116
171
 
117
172
  ## Codebase map (contributors)
118
173
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scip-cli
3
- Version: 2.0.0
3
+ Version: 2.2.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.2.0"
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import re
6
6
 
7
7
  from ..sql import debug_execute
8
- from ..symbols import extract_leaf_name
8
+ from ..symbols import extract_leaf_name, is_module_symbol
9
9
 
10
10
  DEFAULT_LIMIT = 20
11
11
 
@@ -25,8 +25,14 @@ def fetch_one(db, sql: str, params=()):
25
25
 
26
26
 
27
27
  def short_name(symbol: str) -> str:
28
+ if is_module_symbol(symbol):
29
+ return "(module)"
28
30
  leaf = extract_leaf_name(symbol)
29
- return leaf or symbol.split("/")[-1][:60]
31
+ if leaf:
32
+ return leaf
33
+ if symbol.endswith("/"):
34
+ return "(module)"
35
+ return symbol.split("/")[-1][:60]
30
36
 
31
37
 
32
38
  def is_test_path(relative_path: str) -> bool:
@@ -53,10 +59,19 @@ def is_cli_entrypoint(relative_path: str, symbol: str) -> bool:
53
59
  return path == "scip_cli/__main__.py" or "/commands/" in path
54
60
 
55
61
 
62
+ def is_generated_analyze_path(relative_path: str) -> bool:
63
+ p = relative_path.replace("\\", "/")
64
+ if "/types/prisma/" in p:
65
+ return True
66
+ return p.endswith("types/resolvers.ts")
67
+
68
+
56
69
  def analyze_noise(relative_path: str, symbol: str, *, include_tests: bool = False) -> bool:
57
70
  """True for rows that clutter analyze dashboards (test paths, module-private helpers)."""
58
71
  if not include_tests and is_test_path(relative_path):
59
72
  return True
73
+ if is_generated_analyze_path(relative_path):
74
+ return True
60
75
  if short_name(symbol).startswith("_"):
61
76
  return True
62
77
  if is_cli_entrypoint(relative_path, symbol):
@@ -80,12 +95,20 @@ def is_analyze_dashboard_export(relative_path: str, symbol: str) -> bool:
80
95
  return "()." in symbol or name.endswith(")")
81
96
 
82
97
 
98
+ def is_component_props_type(symbol: str) -> bool:
99
+ """React-style Props interfaces — rarely referenced outside the component file."""
100
+ name = short_name(symbol)
101
+ return bool(name and name.endswith("Props") and "#" in symbol.split("/")[-1])
102
+
103
+
83
104
  def stale_type_noise(relative_path: str, symbol: str, consumers: int) -> bool:
84
105
  """Dataclass-style types with no SCIP consumers (typing-only)."""
106
+ if is_component_props_type(symbol):
107
+ return True
85
108
  if consumers > 0:
86
109
  return False
87
110
  name = short_name(symbol)
88
- if not name or not name[0].isupper():
111
+ if not name or name == "(module)" or not name[0].isupper():
89
112
  return False
90
113
  path = relative_path.replace("\\", "/")
91
114
  return path.endswith(("config.py", "scope.py", "analyze/targets.py"))
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from .common import DEFAULT_LIMIT, SYM_DEF_JOIN, analyze_noise, fetch_all, short_name
6
+ from .live import LiveIndex
6
7
  from .sections import Check, Priority, run_checks
7
8
  from .symbol import symbol_pressure
8
9
 
@@ -82,10 +83,11 @@ def file_consumers(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[s
82
83
 
83
84
 
84
85
  def unreferenced_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str]:
86
+ live = LiveIndex(db)
85
87
  rows = fetch_all(
86
88
  db,
87
89
  """
88
- SELECT gs.symbol, der.start_line, der.end_line
90
+ SELECT gs.symbol, der.start_line, der.end_line, def_d.id
89
91
  FROM global_symbols gs
90
92
  JOIN defn_enclosing_ranges der ON gs.id = der.symbol_id
91
93
  JOIN documents def_d ON der.document_id = def_d.id
@@ -104,14 +106,18 @@ def unreferenced_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) ->
104
106
  """,
105
107
  (relative_path, limit),
106
108
  )
107
- return [
108
- f"{short_name(symbol)} {start + 1}:{end + 1}"
109
- for symbol, start, end in rows
110
- if not analyze_noise(relative_path, symbol, include_tests=True)
111
- ]
109
+ lines = []
110
+ for symbol, start, end, def_doc_id in rows:
111
+ if analyze_noise(relative_path, symbol, include_tests=True):
112
+ continue
113
+ if live.dead_export_noise(symbol, def_doc_id):
114
+ continue
115
+ lines.append(f"{short_name(symbol)} {start + 1}:{end + 1}")
116
+ return lines
112
117
 
113
118
 
114
119
  def same_file_only_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str]:
120
+ live = LiveIndex(db)
115
121
  rows = fetch_all(
116
122
  db,
117
123
  """
@@ -135,18 +141,22 @@ def same_file_only_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -
135
141
  """,
136
142
  (relative_path, limit),
137
143
  )
138
- return [
139
- f"{short_name(symbol)} {start + 1}:{end + 1}"
140
- for symbol, start, end in rows
141
- if not analyze_noise(relative_path, symbol, include_tests=True)
142
- ]
144
+ lines = []
145
+ for symbol, start, end in rows:
146
+ if analyze_noise(relative_path, symbol, include_tests=True):
147
+ continue
148
+ if live.same_file_export_noise(symbol):
149
+ continue
150
+ lines.append(f"{short_name(symbol)} {start + 1}:{end + 1}")
151
+ return lines
143
152
 
144
153
 
145
154
  def dead_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str]:
155
+ live = LiveIndex(db)
146
156
  rows = fetch_all(
147
157
  db,
148
158
  """
149
- SELECT gs.symbol, der.start_line, der.end_line
159
+ SELECT gs.symbol, der.start_line, der.end_line, def_d.id
150
160
  FROM global_symbols gs
151
161
  JOIN defn_enclosing_ranges der ON gs.id = der.symbol_id
152
162
  JOIN documents def_d ON der.document_id = def_d.id
@@ -164,11 +174,14 @@ def dead_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str
164
174
  """,
165
175
  (relative_path, limit),
166
176
  )
167
- return [
168
- f"{short_name(symbol)} {start + 1}:{end + 1}"
169
- for symbol, start, end in rows
170
- if not analyze_noise(relative_path, symbol, include_tests=True)
171
- ]
177
+ lines = []
178
+ for symbol, start, end, def_doc_id in rows:
179
+ if analyze_noise(relative_path, symbol, include_tests=True):
180
+ continue
181
+ if live.dead_export_noise(symbol, def_doc_id):
182
+ continue
183
+ lines.append(f"{short_name(symbol)} {start + 1}:{end + 1}")
184
+ return lines
172
185
 
173
186
 
174
187
  def imports_summary(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str]:
@@ -240,6 +253,29 @@ def coupling_for(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str
240
253
  return [f"{other} shared={shared}" for other, shared in rows]
241
254
 
242
255
 
256
+ def count_file_importers(db, relative_path: str) -> int:
257
+ from ..queries import get_file_symbols, get_importer_paths
258
+
259
+ symbols = get_file_symbols(db, relative_path)
260
+ if not symbols:
261
+ return 0
262
+ symbol_ids = [row[0] for row in symbols]
263
+ return len(get_importer_paths(db, symbol_ids, relative_path))
264
+
265
+
266
+ def dead_export_rdeps_warning(db, relative_path: str, *, limit: int = DEFAULT_LIMIT) -> str | None:
267
+ """Warn when dead/unreferenced exports coexist with file importers (SCIP symbol split)."""
268
+ if not dead_in_file(db, relative_path, limit=limit) and not unreferenced_in_file(db, relative_path, limit=limit):
269
+ return None
270
+ importer_count = count_file_importers(db, relative_path)
271
+ if importer_count == 0:
272
+ return None
273
+ return (
274
+ f"Warning: {relative_path} has {importer_count} importer(s) but lists dead/unreferenced exports — "
275
+ "default-export, lazy(), or object-alias false positives are likely; verify with rg or rdeps"
276
+ )
277
+
278
+
243
279
  def top_symbol_pressure(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str]:
244
280
  """Pressure metrics for the most-consumed exports in a file."""
245
281
  cap = min(5, limit)
@@ -0,0 +1,133 @@
1
+ """File dependency graph helpers for analyze (cycle detection)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+
7
+ from ..symbols import cycle_runtime_edge_sql
8
+ from .common import fetch_all
9
+
10
+ FILE_EDGES_SQL = f"""
11
+ SELECT DISTINCT d1.relative_path AS from_file, d2.relative_path AS to_file
12
+ FROM mentions m
13
+ JOIN chunks c ON m.chunk_id = c.id
14
+ JOIN documents d1 ON c.document_id = d1.id
15
+ JOIN defn_enclosing_ranges der ON m.symbol_id = der.symbol_id
16
+ JOIN documents d2 ON der.document_id = d2.id
17
+ JOIN global_symbols gs ON gs.id = der.symbol_id
18
+ WHERE d1.id != d2.id AND m.role != 1
19
+ AND {cycle_runtime_edge_sql()}
20
+ """
21
+
22
+
23
+ def fetch_file_edges(db) -> list[tuple[str, str]]:
24
+ return fetch_all(db, FILE_EDGES_SQL)
25
+
26
+
27
+ def _tarjan_sccs(graph: dict[str, list[str]], nodes: set[str]) -> list[list[str]]:
28
+ index = 0
29
+ stack: list[str] = []
30
+ on_stack: set[str] = set()
31
+ indices: dict[str, int] = {}
32
+ lowlink: dict[str, int] = {}
33
+ sccs: list[list[str]] = []
34
+
35
+ def strongconnect(vertex: str) -> None:
36
+ nonlocal index
37
+ indices[vertex] = index
38
+ lowlink[vertex] = index
39
+ index += 1
40
+ stack.append(vertex)
41
+ on_stack.add(vertex)
42
+ for neighbor in graph.get(vertex, ()):
43
+ if neighbor not in indices:
44
+ strongconnect(neighbor)
45
+ lowlink[vertex] = min(lowlink[vertex], lowlink[neighbor])
46
+ elif neighbor in on_stack:
47
+ lowlink[vertex] = min(lowlink[vertex], indices[neighbor])
48
+ if lowlink[vertex] == indices[vertex]:
49
+ component: list[str] = []
50
+ while True:
51
+ w = stack.pop()
52
+ on_stack.remove(w)
53
+ component.append(w)
54
+ if w == vertex:
55
+ break
56
+ sccs.append(component)
57
+
58
+ for node in nodes:
59
+ if node not in indices:
60
+ strongconnect(node)
61
+ return sccs
62
+
63
+
64
+ def _cycles_in_scc(
65
+ graph: dict[str, list[str]],
66
+ scc_nodes: list[str],
67
+ *,
68
+ max_depth: int,
69
+ limit: int,
70
+ ) -> list[str]:
71
+ if len(scc_nodes) <= 2:
72
+ return []
73
+
74
+ scc = set(scc_nodes)
75
+ subgraph: dict[str, list[str]] = defaultdict(list)
76
+ for src in scc:
77
+ for dst in graph.get(src, ()):
78
+ if dst in scc:
79
+ subgraph[src].append(dst)
80
+
81
+ found: dict[tuple[str, ...], str] = {}
82
+
83
+ def record(path: list[str]) -> None:
84
+ key = min(tuple(path[i:] + path[:i]) for i in range(len(path)))
85
+ if key not in found:
86
+ found[key] = " -> ".join([*path, path[0]])
87
+
88
+ for origin in sorted(scc):
89
+ stack: list[tuple[str, list[str]]] = [(origin, [origin])]
90
+ while stack:
91
+ node, path = stack.pop()
92
+ if len(path) > max_depth:
93
+ continue
94
+ for nxt in subgraph.get(node, ()):
95
+ if nxt == origin and len(path) >= 2:
96
+ record(path)
97
+ if len(found) >= limit:
98
+ return sorted(found.values())[:limit]
99
+ elif nxt not in path:
100
+ stack.append((nxt, [*path, nxt]))
101
+ return sorted(found.values())
102
+
103
+
104
+ def find_longer_cycles(
105
+ edges: list[tuple[str, str]],
106
+ *,
107
+ max_depth: int = 8,
108
+ limit: int,
109
+ ) -> list[str]:
110
+ """Find directed cycles with 3+ distinct files (2-node pairs use the SQL two-way check)."""
111
+ if limit <= 0 or not edges:
112
+ return []
113
+
114
+ graph: dict[str, list[str]] = defaultdict(list)
115
+ nodes: set[str] = set()
116
+ for src, dst in edges:
117
+ graph[src].append(dst)
118
+ nodes.add(src)
119
+ nodes.add(dst)
120
+
121
+ found: dict[tuple[str, ...], str] = {}
122
+ for component in _tarjan_sccs(graph, nodes):
123
+ if len(component) <= 2:
124
+ continue
125
+ for line in _cycles_in_scc(graph, component, max_depth=max_depth, limit=limit):
126
+ path = line.split(" -> ")
127
+ body = path[:-1]
128
+ key = min(tuple(body[i:] + body[:i]) for i in range(len(body)))
129
+ if key not in found:
130
+ found[key] = line
131
+ if len(found) >= limit:
132
+ return sorted(found.values())[:limit]
133
+ return sorted(found.values())[:limit]