scip-cli 2.2.0__tar.gz → 2.2.1__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 (109) hide show
  1. {scip_cli-2.2.0 → scip_cli-2.2.1}/PKG-INFO +2 -2
  2. {scip_cli-2.2.0 → scip_cli-2.2.1}/README.md +1 -1
  3. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/SKILL.md +3 -1
  4. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/__init__.py +1 -1
  5. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/__main__.py +1 -1
  6. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/analyze/common.py +3 -3
  7. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/analyze/file.py +20 -23
  8. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/analyze/live.py +44 -15
  9. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/analyze/project.py +25 -62
  10. scip_cli-2.2.1/scip_cli/analyze/sections.py +120 -0
  11. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/analyze/symbol.py +3 -2
  12. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/commands/analyze.py +32 -13
  13. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_analyze.py +39 -18
  14. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_e2e_analyze_patterns.py +0 -8
  15. scip_cli-2.2.0/scip_cli/analyze/sections.py +0 -77
  16. scip_cli-2.2.0/scripts/verify_rovetia_reports.py +0 -128
  17. {scip_cli-2.2.0 → scip_cli-2.2.1}/.cursor/rules/agent.mdc +0 -0
  18. {scip_cli-2.2.0 → scip_cli-2.2.1}/.gitignore +0 -0
  19. {scip_cli-2.2.0 → scip_cli-2.2.1}/.pre-commit-config.yaml +0 -0
  20. {scip_cli-2.2.0 → scip_cli-2.2.1}/LICENSE +0 -0
  21. {scip_cli-2.2.0 → scip_cli-2.2.1}/pyproject.toml +0 -0
  22. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/analyze/__init__.py +0 -0
  23. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/analyze/graph.py +0 -0
  24. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/analyze/targets.py +0 -0
  25. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/cache.py +0 -0
  26. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/cli_args.py +0 -0
  27. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/commands/__init__.py +0 -0
  28. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/commands/code.py +0 -0
  29. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/commands/members.py +0 -0
  30. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/commands/rdeps.py +0 -0
  31. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/commands/refs.py +0 -0
  32. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/commands/reindex.py +0 -0
  33. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/commands/search.py +0 -0
  34. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/commands/skill.py +0 -0
  35. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/commands/symbols.py +0 -0
  36. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/config.py +0 -0
  37. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/debug.py +0 -0
  38. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/discover.py +0 -0
  39. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/indexing.py +0 -0
  40. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/merge.py +0 -0
  41. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/output.py +0 -0
  42. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/paths.py +0 -0
  43. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/project.py +0 -0
  44. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/queries.py +0 -0
  45. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/scip_tool.py +0 -0
  46. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/scope.py +0 -0
  47. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/session.py +0 -0
  48. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/source.py +0 -0
  49. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/sql.py +0 -0
  50. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/symbols.py +0 -0
  51. {scip_cli-2.2.0 → scip_cli-2.2.1}/scip_cli/targets.py +0 -0
  52. {scip_cli-2.2.0 → scip_cli-2.2.1}/scripts/build.sh +0 -0
  53. {scip_cli-2.2.0 → scip_cli-2.2.1}/scripts/publish.sh +0 -0
  54. {scip_cli-2.2.0 → scip_cli-2.2.1}/scripts/test.sh +0 -0
  55. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/__init__.py +0 -0
  56. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/analyze_db.py +0 -0
  57. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/conftest.py +0 -0
  58. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/e2e_harness.py +0 -0
  59. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixture_catalog.py +0 -0
  60. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/package.json +0 -0
  61. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/app/handler.ts +0 -0
  62. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/config.ts +0 -0
  63. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/consumer.ts +0 -0
  64. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/dead.ts +0 -0
  65. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/domain/i18n/en.ts +0 -0
  66. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/domain/i18n/index.ts +0 -0
  67. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/domain/i18n/useLocale.ts +0 -0
  68. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/domain/labels/index.ts +0 -0
  69. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/events/streamTypes.ts +0 -0
  70. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/gateway/index.ts +0 -0
  71. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/helper.ts +0 -0
  72. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/hooks/useHookA.ts +0 -0
  73. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/hooks/useHookB.ts +0 -0
  74. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/hooks/useItems.ts +0 -0
  75. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/index.ts +0 -0
  76. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/integrations/inferenceClient.ts +0 -0
  77. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/loaders/useInference.ts +0 -0
  78. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/pages/panelPage.ts +0 -0
  79. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/rules/applyRule.ts +0 -0
  80. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/types/a.ts +0 -0
  81. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/types/b.ts +0 -0
  82. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/ui/Button.ts +0 -0
  83. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/ui/LazyPanel.ts +0 -0
  84. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/ui/buttonConsumer.ts +0 -0
  85. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/ui/menuModule.ts +0 -0
  86. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/user.ts +0 -0
  87. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/widget.ts +0 -0
  88. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/widgets/OrphanWidget.ts +0 -0
  89. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/tsconfig.json +0 -0
  90. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/perf_util.py +0 -0
  91. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_analyze_graph.py +0 -0
  92. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_analyze_perf.py +0 -0
  93. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_cache.py +0 -0
  94. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_composability.py +0 -0
  95. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_config.py +0 -0
  96. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_discover.py +0 -0
  97. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_e2e.py +0 -0
  98. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_e2e_perf.py +0 -0
  99. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_index_prune.py +0 -0
  100. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_indexer_env.py +0 -0
  101. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_merge.py +0 -0
  102. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_multi_symbol.py +0 -0
  103. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_pure_functions.py +0 -0
  104. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_qualified_symbols.py +0 -0
  105. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_reindex.py +0 -0
  106. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_scip_tool.py +0 -0
  107. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_scope.py +0 -0
  108. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_targets.py +0 -0
  109. {scip_cli-2.2.0 → scip_cli-2.2.1}/tests/test_typescript_projects.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scip-cli
3
- Version: 2.2.0
3
+ Version: 2.2.1
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
@@ -255,7 +255,7 @@ Use `--priority high` for a quick gate; `--priority high,medium` adds context. F
255
255
  | **Cycles** | Import/mention cycles between production files — break the edge or extract shared code |
256
256
  | **Unreferenced** | No usage in the index at all — delete |
257
257
  | **Dead exports** | No external refs — delete or `_` prefix |
258
- | **Stale types** | Classes/types with ≤1 external consumer — merge, inline, or document why they stay |
258
+ | **Stale types** | Classes/types with no external consumer in the index verify in-file or type-only use before removing |
259
259
  | **Same-file only** | Used only inside defining file — rename to `_` |
260
260
  | **Test-only consumers** | Cross-file refs are all from tests — promote to e2e or accept as internal |
261
261
 
@@ -235,7 +235,7 @@ Use `--priority high` for a quick gate; `--priority high,medium` adds context. F
235
235
  | **Cycles** | Import/mention cycles between production files — break the edge or extract shared code |
236
236
  | **Unreferenced** | No usage in the index at all — delete |
237
237
  | **Dead exports** | No external refs — delete or `_` prefix |
238
- | **Stale types** | Classes/types with ≤1 external consumer — merge, inline, or document why they stay |
238
+ | **Stale types** | Classes/types with no external consumer in the index verify in-file or type-only use before removing |
239
239
  | **Same-file only** | Used only inside defining file — rename to `_` |
240
240
  | **Test-only consumers** | Cross-file refs are all from tests — promote to e2e or accept as internal |
241
241
 
@@ -128,10 +128,12 @@ analyze [--limit N] [--path PATH] [--include-tests] [target]
128
128
 
129
129
  Sections are ordered **high → medium → low**. `[high]` cycles, unreferenced, dead exports, stale types; `[medium]` same-file-only, change surface (file); `[low]` test-only consumers (noisy on Python), coupling, bottlenecks, hotspots.
130
130
 
131
+ `--limit` caps **result rows across the whole run** (default 20); remaining checks are skipped once the cap is reached. `(none)` sections and section headers do not count.
132
+
131
133
  `--priority high` or `--priority high,medium` (also `1`/`2`/`3`) skips lower tiers.
132
134
 
133
135
  Directory detection uses the filesystem when present, otherwise an indexed path prefix. `--path` narrows ambiguous file/symbol resolution only (not directory scope — pass the dir as `target`).
134
136
 
135
137
  **Dogfood loop:** `reindex` → `analyze --limit 25` → `analyze scip_cli` or `analyze scip_cli/queries.py` on suspects. Skips test paths in project-wide and directory runs (`tests/`, `*.test.*`, `*.spec.*`); `--include-tests` to include them. File-target analyze always includes that file.
136
138
 
137
- **Easy pickings:** **Cycles** and **dead exports** (production paths) — cross-file cleanup. **Stale types** — low-use classes. Ignore `analyze/*` section helpers in dead exports. “Dead” = no refs from *other* files in the index, not `vulture`.
139
+ **Easy pickings:** **Cycles** and **dead exports** (production paths) — cross-file cleanup. **Stale types** — types with no external refs in the index. Ignore `analyze/*` section helpers in dead exports. “Dead” = no refs from *other* files in the index, not `vulture`.
@@ -1,3 +1,3 @@
1
1
  """scip-cli: Fast code intelligence via SCIP indexes."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.2.1"
@@ -115,7 +115,7 @@ def main():
115
115
  help="Omit for project-wide; directory for scoped project + per-file; file or symbol for focused checks",
116
116
  )
117
117
  add_path_argument(analyze_parser)
118
- add_limit_argument(analyze_parser, default=20, help_suffix="rows per section")
118
+ add_limit_argument(analyze_parser, default=20, help_suffix="result rows total")
119
119
  analyze_parser.add_argument(
120
120
  "--include-tests",
121
121
  action="store_true",
@@ -127,7 +127,7 @@ def cycle_path_noise(cycle_line: str, *, include_tests: bool = False) -> bool:
127
127
  return any(is_test_path(part.strip()) for part in parts if part.strip())
128
128
 
129
129
 
130
- def section(title: str, lines: list[str]) -> tuple[str, list[str]]:
130
+ def section(title: str, lines: list[str], *, preface: str | None = None) -> tuple[str, list[str], str | None]:
131
131
  if not lines:
132
- return title, ["(none)"]
133
- return title, lines
132
+ return title, ["(none)"], None
133
+ return title, lines, preface
@@ -3,7 +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
+ from .live import LiveIndex, file_has_scip_importers, has_same_file_reference_usage
7
7
  from .sections import Check, Priority, run_checks
8
8
  from .symbol import symbol_pressure
9
9
 
@@ -121,7 +121,7 @@ def same_file_only_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -
121
121
  rows = fetch_all(
122
122
  db,
123
123
  """
124
- SELECT gs.symbol, der.start_line, der.end_line
124
+ SELECT gs.symbol, der.start_line, der.end_line, def_d.id
125
125
  FROM global_symbols gs
126
126
  JOIN defn_enclosing_ranges der ON gs.id = der.symbol_id
127
127
  JOIN documents def_d ON der.document_id = def_d.id
@@ -142,10 +142,12 @@ def same_file_only_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -
142
142
  (relative_path, limit),
143
143
  )
144
144
  lines = []
145
- for symbol, start, end in rows:
145
+ for symbol, start, end, def_doc_id in rows:
146
146
  if analyze_noise(relative_path, symbol, include_tests=True):
147
147
  continue
148
- if live.same_file_export_noise(symbol):
148
+ if live.same_file_export_noise(symbol, def_doc_id):
149
+ continue
150
+ if not file_has_scip_importers(db, relative_path, live=live, def_doc_id=def_doc_id):
149
151
  continue
150
152
  lines.append(f"{short_name(symbol)} {start + 1}:{end + 1}")
151
153
  return lines
@@ -156,7 +158,7 @@ def dead_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str
156
158
  rows = fetch_all(
157
159
  db,
158
160
  """
159
- SELECT gs.symbol, der.start_line, der.end_line, def_d.id
161
+ SELECT gs.id, gs.symbol, der.start_line, der.end_line, def_d.id
160
162
  FROM global_symbols gs
161
163
  JOIN defn_enclosing_ranges der ON gs.id = der.symbol_id
162
164
  JOIN documents def_d ON der.document_id = def_d.id
@@ -175,9 +177,11 @@ def dead_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str
175
177
  (relative_path, limit),
176
178
  )
177
179
  lines = []
178
- for symbol, start, end, def_doc_id in rows:
180
+ for sym_id, symbol, start, end, def_doc_id in rows:
179
181
  if analyze_noise(relative_path, symbol, include_tests=True):
180
182
  continue
183
+ if has_same_file_reference_usage(db, sym_id, def_doc_id):
184
+ continue
181
185
  if live.dead_export_noise(symbol, def_doc_id):
182
186
  continue
183
187
  lines.append(f"{short_name(symbol)} {start + 1}:{end + 1}")
@@ -263,19 +267,6 @@ def count_file_importers(db, relative_path: str) -> int:
263
267
  return len(get_importer_paths(db, symbol_ids, relative_path))
264
268
 
265
269
 
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
-
279
270
  def top_symbol_pressure(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str]:
280
271
  """Pressure metrics for the most-consumed exports in a file."""
281
272
  cap = min(5, limit)
@@ -357,8 +348,10 @@ def _run_file_checks(
357
348
  db,
358
349
  limit: int,
359
350
  priorities,
360
- ) -> list[tuple[str, list[str]]]:
361
- return run_checks(checks, db, limit, priorities)
351
+ *,
352
+ budget=None,
353
+ ) -> list[tuple[str, list[str], str | None]]:
354
+ return run_checks(checks, db, limit, priorities, budget=budget)
362
355
 
363
356
 
364
357
  def run_all(
@@ -366,12 +359,14 @@ def run_all(
366
359
  relative_path: str,
367
360
  limit: int = DEFAULT_LIMIT,
368
361
  priorities=None,
369
- ) -> list[tuple[str, list[str]]]:
362
+ budget=None,
363
+ ) -> list[tuple[str, list[str], str | None]]:
370
364
  return _run_file_checks(
371
365
  _file_checks(relative_path, include_top_symbols=True),
372
366
  db,
373
367
  limit,
374
368
  priorities,
369
+ budget=budget,
375
370
  )
376
371
 
377
372
 
@@ -380,11 +375,13 @@ def run_all_sections_only(
380
375
  relative_path: str,
381
376
  limit: int = DEFAULT_LIMIT,
382
377
  priorities=None,
383
- ) -> list[tuple[str, list[str]]]:
378
+ budget=None,
379
+ ) -> list[tuple[str, list[str], str | None]]:
384
380
  """Per-file sections without top-symbols (directory batch)."""
385
381
  return _run_file_checks(
386
382
  _file_checks(relative_path, include_top_symbols=False),
387
383
  db,
388
384
  limit,
389
385
  priorities,
386
+ budget=budget,
390
387
  )
@@ -64,24 +64,25 @@ class LiveIndex:
64
64
  def __init__(self, db) -> None:
65
65
  self.live_module_docs: set[int] = set()
66
66
  self.live_alias_bases: set[str] = set()
67
+ self.module_importers: dict[int, int] = {}
67
68
  self._build(db)
68
69
 
69
70
  def _build(self, db) -> None:
70
- for (doc_id,) in _fetch_all(
71
+ for doc_id, importer_count in _fetch_all(
71
72
  db,
72
73
  """
73
- SELECT DISTINCT der.document_id
74
+ SELECT der.document_id, COUNT(DISTINCT c.document_id)
74
75
  FROM global_symbols gs
75
76
  JOIN defn_enclosing_ranges der ON der.symbol_id = gs.id
77
+ JOIN mentions m ON m.symbol_id = gs.id AND m.role != 1
78
+ JOIN chunks c ON m.chunk_id = c.id
76
79
  WHERE gs.symbol LIKE '%/' AND gs.symbol NOT LIKE '%().'
77
- AND EXISTS (
78
- SELECT 1 FROM mentions m
79
- JOIN chunks c ON m.chunk_id = c.id
80
- WHERE m.symbol_id = gs.id AND m.role != 1 AND c.document_id != der.document_id
81
- )
80
+ AND c.document_id != der.document_id
81
+ GROUP BY der.document_id
82
82
  """,
83
83
  ):
84
84
  self.live_module_docs.add(doc_id)
85
+ self.module_importers[doc_id] = importer_count
85
86
 
86
87
  for sym_id, symbol in _fetch_all(
87
88
  db,
@@ -99,20 +100,35 @@ class LiveIndex:
99
100
  if _fetch_one(db, _EXTERNAL_MENTION, (sym_id, def_doc)):
100
101
  self.live_alias_bases.add(base)
101
102
 
102
- def dead_export_noise(self, symbol: str, def_doc_id: int) -> bool:
103
+ def possibly_live_label(self, symbol: str, def_doc_id: int) -> str | None:
104
+ """Proof of liveness via module or export alias (used to suppress false dead-export hits)."""
103
105
  if is_module_symbol(symbol):
104
- return True
106
+ if def_doc_id not in self.live_module_docs:
107
+ return None
108
+ count = self.module_importers.get(def_doc_id, 0)
109
+ return f"module_import:{count}"
105
110
  base = export_value_base(symbol)
106
- if base and base in self.live_alias_bases:
107
- return True
108
- return bool(base and def_doc_id in self.live_module_docs)
111
+ if not base:
112
+ return None
113
+ if base in self.live_alias_bases:
114
+ return "export_alias"
115
+ if def_doc_id in self.live_module_docs:
116
+ count = self.module_importers.get(def_doc_id, 0)
117
+ return f"default_export:{count}"
118
+ return None
109
119
 
110
- def same_file_export_noise(self, symbol: str) -> bool:
120
+ def dead_export_noise(self, symbol: str, def_doc_id: int) -> bool:
121
+ """Suppress from dead_exports when live via module or export alias."""
122
+ return self.possibly_live_label(symbol, def_doc_id) is not None or is_module_symbol(symbol)
123
+
124
+ def same_file_export_noise(self, symbol: str, def_doc_id: int | None = None) -> bool:
111
125
  """Export used only in-file but live via module or export alias elsewhere."""
112
126
  base = export_value_base(symbol)
113
127
  if not base:
114
128
  return False
115
- return base in self.live_alias_bases
129
+ if base in self.live_alias_bases:
130
+ return True
131
+ return def_doc_id is not None and def_doc_id in self.live_module_docs
116
132
 
117
133
  def stale_type_live_noise(self, symbol: str, def_doc_id: int) -> bool:
118
134
  """Class/type looks unused but file is live via default export or module import."""
@@ -125,7 +141,7 @@ class LiveIndex:
125
141
 
126
142
 
127
143
  def has_same_file_reference_usage(db, symbol_id: int, def_doc_id: int) -> bool:
128
- """Type or value referenced in the same file (extends, signatures, object literal)."""
144
+ """Type or value referenced in the same file (extends, signatures, handler registration)."""
129
145
  row = _fetch_one(
130
146
  db,
131
147
  """
@@ -137,3 +153,16 @@ def has_same_file_reference_usage(db, symbol_id: int, def_doc_id: int) -> bool:
137
153
  (symbol_id, def_doc_id),
138
154
  )
139
155
  return row is not None
156
+
157
+
158
+ def file_has_scip_importers(db, relative_path: str, *, live: LiveIndex, def_doc_id: int) -> bool:
159
+ """True when the index shows another file importing this module or its symbols."""
160
+ if def_doc_id in live.live_module_docs:
161
+ return True
162
+ from ..queries import get_file_symbols, get_importer_paths
163
+
164
+ symbols = get_file_symbols(db, relative_path)
165
+ if not symbols:
166
+ return False
167
+ symbol_ids = [row[0] for row in symbols]
168
+ return bool(get_importer_paths(db, symbol_ids, relative_path))
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import re
6
6
 
7
7
  from ..paths import path_filter_sql, path_filter_sql_any, path_in_scope
8
+ from ..symbols import is_module_symbol
8
9
  from .common import (
9
10
  DEFAULT_LIMIT,
10
11
  SYM_DEF_JOIN,
@@ -17,7 +18,7 @@ from .common import (
17
18
  stale_type_noise,
18
19
  )
19
20
  from .graph import FILE_EDGES_SQL, fetch_file_edges, find_longer_cycles
20
- from .live import LiveIndex, has_same_file_reference_usage
21
+ from .live import LiveIndex, file_has_scip_importers, has_same_file_reference_usage
21
22
  from .sections import Check, Priority, run_checks
22
23
 
23
24
 
@@ -80,7 +81,7 @@ def bottlenecks(
80
81
  lines = [
81
82
  f"{short_name(symbol)} score={score} loc={loc} fan_in={fan_in} fan_out={fan_out} ({path})"
82
83
  for symbol, path, fan_in, fan_out, loc, score in rows
83
- if not analyze_noise(path, symbol, include_tests=include_tests)
84
+ if not analyze_noise(path, symbol, include_tests=include_tests) and not is_module_symbol(symbol)
84
85
  ]
85
86
  return lines[:limit]
86
87
 
@@ -114,7 +115,7 @@ def hotspots(
114
115
  lines = [
115
116
  f"{short_name(symbol)} refs={ref_count} files={file_count} ({path})"
116
117
  for symbol, path, ref_count, file_count in rows
117
- if not analyze_noise(path, symbol, include_tests=include_tests)
118
+ if not analyze_noise(path, symbol, include_tests=include_tests) and not is_module_symbol(symbol)
118
119
  ]
119
120
  return lines[:limit]
120
121
 
@@ -159,9 +160,11 @@ def _format_dead_export_rows(
159
160
  limit: int,
160
161
  ) -> list[str]:
161
162
  lines = []
162
- for symbol, path, loc, def_doc_id in rows:
163
+ for symbol_id, symbol, path, loc, def_doc_id in rows:
163
164
  if analyze_noise(path, symbol, include_tests=include_tests):
164
165
  continue
166
+ if has_same_file_reference_usage(db, symbol_id, def_doc_id):
167
+ continue
165
168
  if live.dead_export_noise(symbol, def_doc_id):
166
169
  continue
167
170
  lines.append(f"{short_name(symbol)} loc={loc} ({path})")
@@ -192,7 +195,7 @@ def stale_types(
192
195
  AND gs.symbol NOT LIKE '%().'
193
196
  AND gs.symbol NOT LIKE '%#typeLiteral%'{scope_clause}
194
197
  GROUP BY gs.id
195
- HAVING consumers <= 1
198
+ HAVING consumers = 0
196
199
  ORDER BY consumers ASC, def_d.relative_path
197
200
  LIMIT ?
198
201
  """,
@@ -226,7 +229,7 @@ def unreferenced_symbols(
226
229
  rows = fetch_all(
227
230
  db,
228
231
  f"""
229
- SELECT gs.symbol, def_d.relative_path,
232
+ SELECT gs.id, gs.symbol, def_d.relative_path,
230
233
  sym_def.end_line - sym_def.start_line + 1 AS loc,
231
234
  def_d.id
232
235
  FROM global_symbols gs
@@ -261,7 +264,8 @@ def same_file_only(
261
264
  db,
262
265
  f"""
263
266
  SELECT gs.symbol, def_d.relative_path,
264
- sym_def.end_line - sym_def.start_line + 1 AS loc
267
+ sym_def.end_line - sym_def.start_line + 1 AS loc,
268
+ def_d.id
265
269
  FROM global_symbols gs
266
270
  {SYM_DEF_JOIN}
267
271
  WHERE EXISTS (
@@ -280,10 +284,12 @@ def same_file_only(
280
284
  (*scope_params, limit * 5),
281
285
  )
282
286
  lines = []
283
- for symbol, path, loc in rows:
287
+ for symbol, path, loc, def_doc_id in rows:
284
288
  if analyze_noise(path, symbol, include_tests=include_tests):
285
289
  continue
286
- if live.same_file_export_noise(symbol):
290
+ if live.same_file_export_noise(symbol, def_doc_id):
291
+ continue
292
+ if not file_has_scip_importers(db, path, live=live, def_doc_id=def_doc_id):
287
293
  continue
288
294
  lines.append(f"{short_name(symbol)} loc={loc} ({path})")
289
295
  if len(lines) >= limit:
@@ -345,7 +351,7 @@ def dead_exports(
345
351
  rows = fetch_all(
346
352
  db,
347
353
  f"""
348
- SELECT gs.symbol, def_d.relative_path,
354
+ SELECT gs.id, gs.symbol, def_d.relative_path,
349
355
  sym_def.end_line - sym_def.start_line + 1 AS loc,
350
356
  def_d.id
351
357
  FROM global_symbols gs
@@ -366,49 +372,6 @@ def dead_exports(
366
372
  return _format_dead_export_rows(db, rows, LiveIndex(db), include_tests=include_tests, limit=limit)
367
373
 
368
374
 
369
- def possibly_unused_exports(
370
- db,
371
- limit: int = DEFAULT_LIMIT,
372
- *,
373
- include_tests: bool = False,
374
- scope: str | None = None,
375
- ) -> list[str]:
376
- """Exports with no direct external refs but live via module import or export alias."""
377
- scope_clause, scope_params = path_filter_sql(db, scope, doc_alias="def_d")
378
- live = LiveIndex(db)
379
- rows = fetch_all(
380
- db,
381
- f"""
382
- SELECT gs.symbol, def_d.relative_path,
383
- sym_def.end_line - sym_def.start_line + 1 AS loc,
384
- def_d.id
385
- FROM global_symbols gs
386
- {SYM_DEF_JOIN}
387
- WHERE NOT EXISTS (
388
- SELECT 1
389
- FROM mentions m
390
- JOIN chunks c ON m.chunk_id = c.id
391
- WHERE m.symbol_id = gs.id
392
- AND m.role != 1
393
- AND c.document_id != def_d.id
394
- ){scope_clause}
395
- ORDER BY loc DESC, def_d.relative_path
396
- LIMIT ?
397
- """,
398
- (*scope_params, limit * 5),
399
- )
400
- lines = []
401
- for symbol, path, loc, def_doc_id in rows:
402
- if analyze_noise(path, symbol, include_tests=include_tests):
403
- continue
404
- if not live.dead_export_noise(symbol, def_doc_id):
405
- continue
406
- lines.append(f"{short_name(symbol)} loc={loc} (live via module/alias) ({path})")
407
- if len(lines) >= limit:
408
- break
409
- return lines
410
-
411
-
412
375
  def top_coupling(
413
376
  db,
414
377
  limit: int = DEFAULT_LIMIT,
@@ -450,21 +413,21 @@ def run_all(
450
413
  include_tests: bool = False,
451
414
  scope: str | None = None,
452
415
  priorities=None,
453
- ) -> list[tuple[str, list[str]]]:
416
+ budget=None,
417
+ ) -> list[tuple[str, list[str], str | None]]:
454
418
  suffix = _scope_suffix(scope)
455
- opts = {"include_tests": include_tests, "scope": scope}
419
+ opts = {"include_tests": include_tests, "scope": scope, "budget": budget}
456
420
  checks = [
457
421
  Check("cycles", Priority.HIGH, f"Cycles (file dependencies){suffix}", cycles),
458
422
  Check("unreferenced", Priority.HIGH, f"Unreferenced symbols (no refs){suffix}", unreferenced_symbols),
459
- Check("dead_exports", Priority.HIGH, f"Dead exports (no external refs){suffix}", dead_exports),
460
423
  Check(
461
- "possibly_unused",
462
- Priority.MEDIUM,
463
- f"Possibly unused exports (live via module/alias){suffix}",
464
- possibly_unused_exports,
424
+ "dead_exports",
425
+ Priority.HIGH,
426
+ f"Dead exports (no in-file or external use){suffix}",
427
+ dead_exports,
465
428
  ),
466
- Check("stale_types", Priority.HIGH, f"Stale types (≤1 external consumer){suffix}", stale_types),
467
- Check("same_file_only", Priority.MEDIUM, f"Same-file only (consider _prefix){suffix}", same_file_only),
429
+ Check("stale_types", Priority.HIGH, f"Stale types (no external consumers){suffix}", stale_types),
430
+ Check("same_file_only", Priority.MEDIUM, f"Same-file only (in-file use, not exported){suffix}", same_file_only),
468
431
  Check(
469
432
  "test_only",
470
433
  Priority.LOW,
@@ -0,0 +1,120 @@
1
+ """Priority ordering and filtering for analyze dashboards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+ from .common import section
11
+
12
+ CheckFn = Callable[..., list[str]]
13
+
14
+
15
+ class Priority(str, Enum):
16
+ HIGH = "high"
17
+ MEDIUM = "medium"
18
+ LOW = "low"
19
+
20
+
21
+ _ORDER = (Priority.HIGH, Priority.MEDIUM, Priority.LOW)
22
+ _ALIASES = {
23
+ "1": Priority.HIGH,
24
+ "2": Priority.MEDIUM,
25
+ "3": Priority.LOW,
26
+ "h": Priority.HIGH,
27
+ "m": Priority.MEDIUM,
28
+ "l": Priority.LOW,
29
+ }
30
+
31
+
32
+ def parse_priorities(value: str | None) -> set[Priority] | None:
33
+ """Parse --priority (comma-separated names or 1/2/3). None means all levels."""
34
+ if not value:
35
+ return None
36
+ out: set[Priority] = set()
37
+ for part in value.replace(" ", "").split(","):
38
+ if not part:
39
+ continue
40
+ key = part.lower()
41
+ if key in _ALIASES:
42
+ out.add(_ALIASES[key])
43
+ continue
44
+ try:
45
+ out.add(Priority(key))
46
+ except ValueError as exc:
47
+ allowed = ", ".join(p.value for p in Priority)
48
+ raise RuntimeError(f"unknown analyze priority {part!r} (use {allowed} or 1/2/3)") from exc
49
+ return out
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Check:
54
+ key: str
55
+ priority: Priority
56
+ title: str
57
+ run: CheckFn
58
+ false_positive_preface: str | None = None
59
+
60
+ def labeled_title(self) -> str:
61
+ return f"[{self.priority.value}] {self.title}"
62
+
63
+
64
+ # Shown only when the section has hits (not "(none)").
65
+ FALSE_POSITIVE_PREFACES: dict[str, str] = {
66
+ "dead_exports": (
67
+ "SCIP may miss dynamic loading (loadFiles, GraphQL) and default-export object members "
68
+ "— verify with rdeps/rg before deleting."
69
+ ),
70
+ "unreferenced": (
71
+ "No mentions in the index — symbols may still run via dynamic import or side-effect registration."
72
+ ),
73
+ "same_file_only": ("Referenced only in the defining file — often handlers or private helpers, not dead exports."),
74
+ "stale_types": ("No cross-file refs in the index — may still be used in-file or as a type-only shape."),
75
+ "cycles": "Remaining cycles may be barrel re-exports; confirm before refactoring.",
76
+ "dead_in_file": (
77
+ "SCIP may miss dynamic loading and default-export indirection — verify with rdeps/rg before deleting."
78
+ ),
79
+ "unreferenced_in_file": (
80
+ "No mentions in the index — may still be used in-file via handlers or dynamic registration."
81
+ ),
82
+ }
83
+
84
+
85
+ def _preface_for(key: str) -> str | None:
86
+ return FALSE_POSITIVE_PREFACES.get(key)
87
+
88
+
89
+ @dataclass
90
+ class RowBudget:
91
+ """Shared cap on result rows across analyze sections."""
92
+
93
+ remaining: int
94
+
95
+ def exhausted(self) -> bool:
96
+ return self.remaining <= 0
97
+
98
+
99
+ def run_checks(
100
+ checks: list[Check],
101
+ db,
102
+ limit: int,
103
+ priorities: set[Priority] | None,
104
+ **kwargs: Any,
105
+ ) -> list[tuple[str, list[str], str | None]]:
106
+ """Run checks in priority order (high → low), optionally filtered."""
107
+ selected = [check for check in checks if priorities is None or check.priority in priorities]
108
+ selected.sort(key=lambda check: (_ORDER.index(check.priority), check.key))
109
+ budget: RowBudget = kwargs.pop("budget", None) or RowBudget(remaining=limit)
110
+ sections: list[tuple[str, list[str], str | None]] = []
111
+ for check in selected:
112
+ if budget.exhausted():
113
+ break
114
+ lines = check.run(db, budget.remaining, **kwargs)
115
+ if lines != ["(none)"]:
116
+ lines = lines[: budget.remaining]
117
+ budget.remaining -= len(lines)
118
+ preface = check.false_positive_preface if check.false_positive_preface else _preface_for(check.key)
119
+ sections.append(section(check.labeled_title(), lines, preface=preface))
120
+ return sections
@@ -168,7 +168,8 @@ def run_all(
168
168
  symbol_id: int,
169
169
  limit: int = DEFAULT_LIMIT,
170
170
  priorities=None,
171
- ) -> list[tuple[str, list[str]]]:
171
+ budget=None,
172
+ ) -> list[tuple[str, list[str], str | None]]:
172
173
  checks = [
173
174
  Check("consumer_files", Priority.HIGH, "Consumer files (direct)", _bind_symbol(consumer_files, symbol_id)),
174
175
  Check("dependencies", Priority.HIGH, "Dependencies (cross-file)", _bind_symbol(dependencies, symbol_id)),
@@ -181,4 +182,4 @@ def run_all(
181
182
  ),
182
183
  Check("def_context", Priority.LOW, "Definition context", _bind_symbol0(def_context, symbol_id)),
183
184
  ]
184
- return run_checks(checks, db, limit, priorities)
185
+ return run_checks(checks, db, limit, priorities, budget=budget)