scip-cli 2.1.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 (111) hide show
  1. {scip_cli-2.1.0 → scip_cli-2.2.0}/.cursor/rules/agent.mdc +33 -10
  2. {scip_cli-2.1.0 → scip_cli-2.2.0}/PKG-INFO +1 -1
  3. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/__init__.py +1 -1
  4. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/analyze/common.py +26 -3
  5. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/analyze/file.py +53 -17
  6. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/analyze/graph.py +4 -1
  7. scip_cli-2.2.0/scip_cli/analyze/live.py +139 -0
  8. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/analyze/project.py +103 -28
  9. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/commands/analyze.py +3 -0
  10. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/commands/refs.py +4 -2
  11. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/output.py +17 -0
  12. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/queries.py +21 -0
  13. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/symbols.py +26 -0
  14. scip_cli-2.2.0/scripts/publish.sh +96 -0
  15. scip_cli-2.2.0/scripts/verify_rovetia_reports.py +128 -0
  16. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/analyze_db.py +63 -0
  17. scip_cli-2.2.0/tests/fixture_catalog.py +44 -0
  18. scip_cli-2.2.0/tests/fixtures/sample-project/src/domain/i18n/en.ts +3 -0
  19. scip_cli-2.2.0/tests/fixtures/sample-project/src/domain/i18n/index.ts +3 -0
  20. scip_cli-2.2.0/tests/fixtures/sample-project/src/domain/i18n/useLocale.ts +5 -0
  21. scip_cli-2.2.0/tests/fixtures/sample-project/src/domain/labels/index.ts +3 -0
  22. scip_cli-2.2.0/tests/fixtures/sample-project/src/events/streamTypes.ts +7 -0
  23. scip_cli-2.2.0/tests/fixtures/sample-project/src/gateway/index.ts +3 -0
  24. scip_cli-2.2.0/tests/fixtures/sample-project/src/hooks/useHookA.ts +7 -0
  25. scip_cli-2.2.0/tests/fixtures/sample-project/src/hooks/useHookB.ts +7 -0
  26. scip_cli-2.2.0/tests/fixtures/sample-project/src/hooks/useItems.ts +5 -0
  27. scip_cli-2.2.0/tests/fixtures/sample-project/src/integrations/inferenceClient.ts +7 -0
  28. scip_cli-2.2.0/tests/fixtures/sample-project/src/loaders/useInference.ts +5 -0
  29. scip_cli-2.2.0/tests/fixtures/sample-project/src/pages/panelPage.ts +4 -0
  30. scip_cli-2.2.0/tests/fixtures/sample-project/src/rules/applyRule.ts +5 -0
  31. scip_cli-2.2.0/tests/fixtures/sample-project/src/types/a.ts +3 -0
  32. scip_cli-2.2.0/tests/fixtures/sample-project/src/types/b.ts +3 -0
  33. scip_cli-2.2.0/tests/fixtures/sample-project/src/ui/Button.ts +7 -0
  34. scip_cli-2.2.0/tests/fixtures/sample-project/src/ui/LazyPanel.ts +3 -0
  35. scip_cli-2.2.0/tests/fixtures/sample-project/src/ui/buttonConsumer.ts +5 -0
  36. scip_cli-2.2.0/tests/fixtures/sample-project/src/ui/menuModule.ts +1 -0
  37. scip_cli-2.2.0/tests/fixtures/sample-project/src/widgets/OrphanWidget.ts +3 -0
  38. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_analyze.py +38 -4
  39. scip_cli-2.2.0/tests/test_analyze_graph.py +66 -0
  40. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_e2e.py +2 -2
  41. scip_cli-2.2.0/tests/test_e2e_analyze_patterns.py +176 -0
  42. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_pure_functions.py +9 -1
  43. scip_cli-2.1.0/scripts/publish.sh +0 -32
  44. scip_cli-2.1.0/tests/fixture_catalog.py +0 -17
  45. scip_cli-2.1.0/tests/test_analyze_graph.py +0 -30
  46. {scip_cli-2.1.0 → scip_cli-2.2.0}/.gitignore +0 -0
  47. {scip_cli-2.1.0 → scip_cli-2.2.0}/.pre-commit-config.yaml +0 -0
  48. {scip_cli-2.1.0 → scip_cli-2.2.0}/LICENSE +0 -0
  49. {scip_cli-2.1.0 → scip_cli-2.2.0}/README.md +0 -0
  50. {scip_cli-2.1.0 → scip_cli-2.2.0}/pyproject.toml +0 -0
  51. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/SKILL.md +0 -0
  52. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/__main__.py +0 -0
  53. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/analyze/__init__.py +0 -0
  54. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/analyze/sections.py +0 -0
  55. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/analyze/symbol.py +0 -0
  56. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/analyze/targets.py +0 -0
  57. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/cache.py +0 -0
  58. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/cli_args.py +0 -0
  59. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/commands/__init__.py +0 -0
  60. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/commands/code.py +0 -0
  61. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/commands/members.py +0 -0
  62. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/commands/rdeps.py +0 -0
  63. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/commands/reindex.py +0 -0
  64. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/commands/search.py +0 -0
  65. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/commands/skill.py +0 -0
  66. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/commands/symbols.py +0 -0
  67. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/config.py +0 -0
  68. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/debug.py +0 -0
  69. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/discover.py +0 -0
  70. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/indexing.py +0 -0
  71. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/merge.py +0 -0
  72. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/paths.py +0 -0
  73. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/project.py +0 -0
  74. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/scip_tool.py +0 -0
  75. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/scope.py +0 -0
  76. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/session.py +0 -0
  77. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/source.py +0 -0
  78. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/sql.py +0 -0
  79. {scip_cli-2.1.0 → scip_cli-2.2.0}/scip_cli/targets.py +0 -0
  80. {scip_cli-2.1.0 → scip_cli-2.2.0}/scripts/build.sh +0 -0
  81. {scip_cli-2.1.0 → scip_cli-2.2.0}/scripts/test.sh +0 -0
  82. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/__init__.py +0 -0
  83. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/conftest.py +0 -0
  84. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/e2e_harness.py +0 -0
  85. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/package.json +0 -0
  86. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/app/handler.ts +0 -0
  87. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/config.ts +0 -0
  88. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/consumer.ts +0 -0
  89. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/dead.ts +0 -0
  90. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/helper.ts +0 -0
  91. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/index.ts +0 -0
  92. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/user.ts +0 -0
  93. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/src/widget.ts +0 -0
  94. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/fixtures/sample-project/tsconfig.json +0 -0
  95. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/perf_util.py +0 -0
  96. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_analyze_perf.py +0 -0
  97. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_cache.py +0 -0
  98. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_composability.py +0 -0
  99. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_config.py +0 -0
  100. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_discover.py +0 -0
  101. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_e2e_perf.py +0 -0
  102. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_index_prune.py +0 -0
  103. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_indexer_env.py +0 -0
  104. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_merge.py +0 -0
  105. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_multi_symbol.py +0 -0
  106. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_qualified_symbols.py +0 -0
  107. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_reindex.py +0 -0
  108. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_scip_tool.py +0 -0
  109. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_scope.py +0 -0
  110. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_targets.py +0 -0
  111. {scip_cli-2.1.0 → scip_cli-2.2.0}/tests/test_typescript_projects.py +0 -0
@@ -31,7 +31,7 @@ Node.js + `npx` required for integration tests (`scip-typescript`). Optional: `S
31
31
  | Task | Command |
32
32
  |------|---------|
33
33
  | Full suite | `pytest` |
34
- | E2e loop | `pytest tests/test_e2e.py` |
34
+ | E2e loop | `pytest tests/test_e2e.py tests/test_e2e_analyze_patterns.py` |
35
35
  | Lint / format | `ruff check .` / `ruff format .` |
36
36
 
37
37
  Pre-commit: ruff + ruff-format + full pytest. E2e before commit on command changes.
@@ -69,9 +69,10 @@ When you or a review subagent finds a repo issue — bug, dead code, cycle, risk
69
69
 
70
70
  If **yes** (the signal is in `documents`, `mentions`, `global_symbols`, `defn_enclosing_ranges`):
71
71
 
72
- 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`.
73
- 2. **Dogfood** — `reindex` confirm the issue appears (or the false positive is filtered).
74
- 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).
75
76
 
76
77
  Only skip step 1 when the issue is **outside the index** (runtime, CLI UX, docs drift, git, env, concurrency, test harness).
77
78
 
@@ -137,14 +138,36 @@ The e2e fixture and this repo's own index are **small** — every query looks fa
137
138
 
138
139
  ## TDD
139
140
 
140
- 1. **Red** `tests/test_e2e.py` + `fixture_catalog` constants.
141
- 2. **Green** — `scip_cli/commands/*.py`.
142
- 3. **Refactor** — unit tests for pure helpers only.
141
+ ### Default: extend the shared fixture
143
142
 
144
- - **E2e** (`cli` fixture): in-process `main()`, TS project indexed once per session default for behavior.
145
- - **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.
146
150
  - **No subprocess** for CLI tests; no `reindex` in e2e (mutates `~/.cache/scip-cli`).
147
- - 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.
148
171
 
149
172
  ## Codebase map (contributors)
150
173
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scip-cli
3
- Version: 2.1.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
@@ -1,3 +1,3 @@
1
1
  """scip-cli: Fast code intelligence via SCIP indexes."""
2
2
 
3
- __version__ = "2.1.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)
@@ -4,16 +4,19 @@ from __future__ import annotations
4
4
 
5
5
  from collections import defaultdict
6
6
 
7
+ from ..symbols import cycle_runtime_edge_sql
7
8
  from .common import fetch_all
8
9
 
9
- FILE_EDGES_SQL = """
10
+ FILE_EDGES_SQL = f"""
10
11
  SELECT DISTINCT d1.relative_path AS from_file, d2.relative_path AS to_file
11
12
  FROM mentions m
12
13
  JOIN chunks c ON m.chunk_id = c.id
13
14
  JOIN documents d1 ON c.document_id = d1.id
14
15
  JOIN defn_enclosing_ranges der ON m.symbol_id = der.symbol_id
15
16
  JOIN documents d2 ON der.document_id = d2.id
17
+ JOIN global_symbols gs ON gs.id = der.symbol_id
16
18
  WHERE d1.id != d2.id AND m.role != 1
19
+ AND {cycle_runtime_edge_sql()}
17
20
  """
18
21
 
19
22
 
@@ -0,0 +1,139 @@
1
+ """Heuristics for SCIP symbols that look dead but are live via module or export-alias indirection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ..sql import debug_execute
8
+ from ..symbols import extract_leaf_name, is_module_symbol
9
+
10
+ _EXTERNAL_MENTION = """
11
+ SELECT 1 FROM mentions m
12
+ JOIN chunks c ON m.chunk_id = c.id
13
+ WHERE m.symbol_id = ? AND m.role != 1 AND c.document_id != ?
14
+ LIMIT 1
15
+ """
16
+
17
+
18
+ def _fetch_all(db, sql: str, params=()) -> list:
19
+ return debug_execute(db, sql, params).fetchall()
20
+
21
+
22
+ def _fetch_one(db, sql: str, params=()):
23
+ return debug_execute(db, sql, params).fetchone()
24
+
25
+
26
+ def is_export_alias_symbol(symbol: str) -> bool:
27
+ tail = symbol.split("/")[-1]
28
+ return tail.endswith("0:") and "()." not in tail
29
+
30
+
31
+ def export_alias_base(symbol: str) -> str | None:
32
+ if not is_export_alias_symbol(symbol):
33
+ return None
34
+ tail = symbol.split("/")[-1]
35
+ return tail[:-2]
36
+
37
+
38
+ def export_value_base(symbol: str) -> str | None:
39
+ """Base name for a default-exported function or class/type definition."""
40
+ if symbol.endswith("()."):
41
+ return extract_leaf_name(symbol) or None
42
+ if symbol.endswith("#") and "#typeLiteral" not in symbol and "()." not in symbol.split("/")[-1]:
43
+ leaf = extract_leaf_name(symbol)
44
+ return leaf or None
45
+ return None
46
+
47
+
48
+ def _def_doc_id_from_symbol(db, symbol: str) -> int | None:
49
+ match = re.search(r"`([^`]+)`", symbol)
50
+ if not match:
51
+ return None
52
+ filename = match.group(1)
53
+ row = _fetch_one(
54
+ db,
55
+ "SELECT id FROM documents WHERE relative_path GLOB ? OR relative_path = ? LIMIT 1",
56
+ (f"*/{filename}", filename),
57
+ )
58
+ return row[0] if row else None
59
+
60
+
61
+ class LiveIndex:
62
+ """Precomputed live-module docs and export-alias bases for one analyze pass."""
63
+
64
+ def __init__(self, db) -> None:
65
+ self.live_module_docs: set[int] = set()
66
+ self.live_alias_bases: set[str] = set()
67
+ self._build(db)
68
+
69
+ def _build(self, db) -> None:
70
+ for (doc_id,) in _fetch_all(
71
+ db,
72
+ """
73
+ SELECT DISTINCT der.document_id
74
+ FROM global_symbols gs
75
+ JOIN defn_enclosing_ranges der ON der.symbol_id = gs.id
76
+ 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
+ )
82
+ """,
83
+ ):
84
+ self.live_module_docs.add(doc_id)
85
+
86
+ for sym_id, symbol in _fetch_all(
87
+ db,
88
+ """
89
+ SELECT id, symbol FROM global_symbols
90
+ WHERE symbol LIKE '%0:' AND symbol NOT LIKE '%().'
91
+ """,
92
+ ):
93
+ base = export_alias_base(symbol)
94
+ if not base:
95
+ continue
96
+ def_doc = _def_doc_id_from_symbol(db, symbol)
97
+ if def_doc is None:
98
+ continue
99
+ if _fetch_one(db, _EXTERNAL_MENTION, (sym_id, def_doc)):
100
+ self.live_alias_bases.add(base)
101
+
102
+ def dead_export_noise(self, symbol: str, def_doc_id: int) -> bool:
103
+ if is_module_symbol(symbol):
104
+ return True
105
+ 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)
109
+
110
+ def same_file_export_noise(self, symbol: str) -> bool:
111
+ """Export used only in-file but live via module or export alias elsewhere."""
112
+ base = export_value_base(symbol)
113
+ if not base:
114
+ return False
115
+ return base in self.live_alias_bases
116
+
117
+ def stale_type_live_noise(self, symbol: str, def_doc_id: int) -> bool:
118
+ """Class/type looks unused but file is live via default export or module import."""
119
+ base = export_value_base(symbol)
120
+ if not base:
121
+ return False
122
+ if base in self.live_alias_bases:
123
+ return True
124
+ return def_doc_id in self.live_module_docs
125
+
126
+
127
+ 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)."""
129
+ row = _fetch_one(
130
+ db,
131
+ """
132
+ SELECT 1 FROM mentions m
133
+ JOIN chunks c ON m.chunk_id = c.id
134
+ WHERE m.symbol_id = ? AND m.role = 0 AND c.document_id = ?
135
+ LIMIT 1
136
+ """,
137
+ (symbol_id, def_doc_id),
138
+ )
139
+ return row is not None
@@ -17,6 +17,7 @@ from .common import (
17
17
  stale_type_noise,
18
18
  )
19
19
  from .graph import FILE_EDGES_SQL, fetch_file_edges, find_longer_cycles
20
+ from .live import LiveIndex, has_same_file_reference_usage
20
21
  from .sections import Check, Priority, run_checks
21
22
 
22
23
 
@@ -149,6 +150,26 @@ def cycles(
149
150
  return lines[:limit]
150
151
 
151
152
 
153
+ def _format_dead_export_rows(
154
+ db,
155
+ rows,
156
+ live: LiveIndex,
157
+ *,
158
+ include_tests: bool,
159
+ limit: int,
160
+ ) -> list[str]:
161
+ lines = []
162
+ for symbol, path, loc, def_doc_id in rows:
163
+ if analyze_noise(path, symbol, include_tests=include_tests):
164
+ continue
165
+ if live.dead_export_noise(symbol, def_doc_id):
166
+ continue
167
+ lines.append(f"{short_name(symbol)} loc={loc} ({path})")
168
+ if len(lines) >= limit:
169
+ break
170
+ return lines
171
+
172
+
152
173
  def stale_types(
153
174
  db,
154
175
  limit: int = DEFAULT_LIMIT,
@@ -160,7 +181,7 @@ def stale_types(
160
181
  rows = fetch_all(
161
182
  db,
162
183
  f"""
163
- SELECT gs.symbol, def_d.relative_path,
184
+ SELECT gs.id, gs.symbol, def_d.relative_path, def_d.id,
164
185
  COUNT(DISTINCT CASE WHEN ref_d.id != def_d.id THEN ref_d.id END) AS consumers
165
186
  FROM global_symbols gs
166
187
  {SYM_DEF_JOIN}
@@ -177,13 +198,21 @@ def stale_types(
177
198
  """,
178
199
  (*scope_params, limit * 5),
179
200
  )
180
- lines = [
181
- f"{short_name(symbol)} consumers={consumers} ({path})"
182
- for symbol, path, consumers in rows
183
- if not analyze_noise(path, symbol, include_tests=include_tests)
184
- and not stale_type_noise(path, symbol, consumers)
185
- ]
186
- return lines[:limit]
201
+ lines = []
202
+ live = LiveIndex(db)
203
+ for sym_id, symbol, path, def_doc_id, consumers in rows:
204
+ if analyze_noise(path, symbol, include_tests=include_tests):
205
+ continue
206
+ if stale_type_noise(path, symbol, consumers):
207
+ continue
208
+ if consumers == 0 and has_same_file_reference_usage(db, sym_id, def_doc_id):
209
+ continue
210
+ if live.stale_type_live_noise(symbol, def_doc_id):
211
+ continue
212
+ lines.append(f"{short_name(symbol)} consumers={consumers} ({path})")
213
+ if len(lines) >= limit:
214
+ break
215
+ return lines
187
216
 
188
217
 
189
218
  def unreferenced_symbols(
@@ -198,7 +227,8 @@ def unreferenced_symbols(
198
227
  db,
199
228
  f"""
200
229
  SELECT gs.symbol, def_d.relative_path,
201
- sym_def.end_line - sym_def.start_line + 1 AS loc
230
+ sym_def.end_line - sym_def.start_line + 1 AS loc,
231
+ def_d.id
202
232
  FROM global_symbols gs
203
233
  {SYM_DEF_JOIN}
204
234
  WHERE NOT EXISTS (
@@ -215,12 +245,7 @@ def unreferenced_symbols(
215
245
  """,
216
246
  (*scope_params, limit * 5),
217
247
  )
218
- lines = [
219
- f"{short_name(symbol)} loc={loc} ({path})"
220
- for symbol, path, loc in rows
221
- if not analyze_noise(path, symbol, include_tests=include_tests)
222
- ]
223
- return lines[:limit]
248
+ return _format_dead_export_rows(db, rows, LiveIndex(db), include_tests=include_tests, limit=limit)
224
249
 
225
250
 
226
251
  def same_file_only(
@@ -231,6 +256,7 @@ def same_file_only(
231
256
  scope: str | None = None,
232
257
  ) -> list[str]:
233
258
  scope_clause, scope_params = path_filter_sql(db, scope, doc_alias="def_d")
259
+ live = LiveIndex(db)
234
260
  rows = fetch_all(
235
261
  db,
236
262
  f"""
@@ -253,12 +279,16 @@ def same_file_only(
253
279
  """,
254
280
  (*scope_params, limit * 5),
255
281
  )
256
- lines = [
257
- f"{short_name(symbol)} loc={loc} ({path})"
258
- for symbol, path, loc in rows
259
- if not analyze_noise(path, symbol, include_tests=include_tests)
260
- ]
261
- return lines[:limit]
282
+ lines = []
283
+ for symbol, path, loc in rows:
284
+ if analyze_noise(path, symbol, include_tests=include_tests):
285
+ continue
286
+ if live.same_file_export_noise(symbol):
287
+ continue
288
+ lines.append(f"{short_name(symbol)} loc={loc} ({path})")
289
+ if len(lines) >= limit:
290
+ break
291
+ return lines
262
292
 
263
293
 
264
294
  def symbols_test_only_consumers(
@@ -316,7 +346,8 @@ def dead_exports(
316
346
  db,
317
347
  f"""
318
348
  SELECT gs.symbol, def_d.relative_path,
319
- sym_def.end_line - sym_def.start_line + 1 AS loc
349
+ sym_def.end_line - sym_def.start_line + 1 AS loc,
350
+ def_d.id
320
351
  FROM global_symbols gs
321
352
  {SYM_DEF_JOIN}
322
353
  WHERE NOT EXISTS (
@@ -332,12 +363,50 @@ def dead_exports(
332
363
  """,
333
364
  (*scope_params, limit * 5),
334
365
  )
335
- lines = [
336
- f"{short_name(symbol)} loc={loc} ({path})"
337
- for symbol, path, loc in rows
338
- if not analyze_noise(path, symbol, include_tests=include_tests)
339
- ]
340
- return lines[:limit]
366
+ return _format_dead_export_rows(db, rows, LiveIndex(db), include_tests=include_tests, limit=limit)
367
+
368
+
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
341
410
 
342
411
 
343
412
  def top_coupling(
@@ -388,6 +457,12 @@ def run_all(
388
457
  Check("cycles", Priority.HIGH, f"Cycles (file dependencies){suffix}", cycles),
389
458
  Check("unreferenced", Priority.HIGH, f"Unreferenced symbols (no refs){suffix}", unreferenced_symbols),
390
459
  Check("dead_exports", Priority.HIGH, f"Dead exports (no external refs){suffix}", dead_exports),
460
+ Check(
461
+ "possibly_unused",
462
+ Priority.MEDIUM,
463
+ f"Possibly unused exports (live via module/alias){suffix}",
464
+ possibly_unused_exports,
465
+ ),
391
466
  Check("stale_types", Priority.HIGH, f"Stale types (≤1 external consumer){suffix}", stale_types),
392
467
  Check("same_file_only", Priority.MEDIUM, f"Same-file only (consider _prefix){suffix}", same_file_only),
393
468
  Check(
@@ -45,6 +45,9 @@ def _project_sections(
45
45
 
46
46
 
47
47
  def _file_sections(db, path: str, *, limit: int, priorities) -> list[tuple[str, list[str]]]:
48
+ warn = file_checks.dead_export_rdeps_warning(db, path, limit=limit)
49
+ if warn:
50
+ print(warn, file=sys.stderr)
48
51
  return file_checks.run_all(db, path, limit=limit, priorities=priorities)
49
52
 
50
53
 
@@ -4,7 +4,7 @@ import re
4
4
  import sys
5
5
 
6
6
  from ..cli_args import path_scope_from_args
7
- from ..output import limit_and_warn, maybe_print_symbol_header, symbol_output_label
7
+ from ..output import limit_and_warn, maybe_print_symbol_header, symbol_output_label, warn_ambiguous_refs
8
8
  from ..paths import path_filter_sql, path_in_scope
9
9
  from ..queries import resolve_symbol
10
10
  from ..session import setup
@@ -120,7 +120,9 @@ def _resolve_symbol_groups(db, names, limit, path_scope):
120
120
  if not symbols:
121
121
  print(f"Symbol '{query_name}' not found", file=sys.stderr)
122
122
  continue
123
- groups.append((query_name, limit_and_warn(symbols, limit, "symbols")))
123
+ trimmed = limit_and_warn(symbols, limit, "symbols")
124
+ warn_ambiguous_refs(query_name, trimmed, db)
125
+ groups.append((query_name, trimmed))
124
126
  return groups
125
127
 
126
128