scip-cli 2.1.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 (111) hide show
  1. {scip_cli-2.1.0 → scip_cli-2.2.1}/.cursor/rules/agent.mdc +33 -10
  2. {scip_cli-2.1.0 → scip_cli-2.2.1}/PKG-INFO +2 -2
  3. {scip_cli-2.1.0 → scip_cli-2.2.1}/README.md +1 -1
  4. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/SKILL.md +3 -1
  5. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/__init__.py +1 -1
  6. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/__main__.py +1 -1
  7. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/analyze/common.py +29 -6
  8. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/analyze/file.py +55 -22
  9. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/analyze/graph.py +4 -1
  10. scip_cli-2.2.1/scip_cli/analyze/live.py +168 -0
  11. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/analyze/project.py +77 -39
  12. scip_cli-2.2.1/scip_cli/analyze/sections.py +120 -0
  13. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/analyze/symbol.py +3 -2
  14. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/commands/analyze.py +32 -10
  15. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/commands/refs.py +4 -2
  16. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/output.py +17 -0
  17. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/queries.py +21 -0
  18. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/symbols.py +26 -0
  19. scip_cli-2.2.1/scripts/publish.sh +96 -0
  20. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/analyze_db.py +63 -0
  21. scip_cli-2.2.1/tests/fixture_catalog.py +44 -0
  22. scip_cli-2.2.1/tests/fixtures/sample-project/src/domain/i18n/en.ts +3 -0
  23. scip_cli-2.2.1/tests/fixtures/sample-project/src/domain/i18n/index.ts +3 -0
  24. scip_cli-2.2.1/tests/fixtures/sample-project/src/domain/i18n/useLocale.ts +5 -0
  25. scip_cli-2.2.1/tests/fixtures/sample-project/src/domain/labels/index.ts +3 -0
  26. scip_cli-2.2.1/tests/fixtures/sample-project/src/events/streamTypes.ts +7 -0
  27. scip_cli-2.2.1/tests/fixtures/sample-project/src/gateway/index.ts +3 -0
  28. scip_cli-2.2.1/tests/fixtures/sample-project/src/hooks/useHookA.ts +7 -0
  29. scip_cli-2.2.1/tests/fixtures/sample-project/src/hooks/useHookB.ts +7 -0
  30. scip_cli-2.2.1/tests/fixtures/sample-project/src/hooks/useItems.ts +5 -0
  31. scip_cli-2.2.1/tests/fixtures/sample-project/src/integrations/inferenceClient.ts +7 -0
  32. scip_cli-2.2.1/tests/fixtures/sample-project/src/loaders/useInference.ts +5 -0
  33. scip_cli-2.2.1/tests/fixtures/sample-project/src/pages/panelPage.ts +4 -0
  34. scip_cli-2.2.1/tests/fixtures/sample-project/src/rules/applyRule.ts +5 -0
  35. scip_cli-2.2.1/tests/fixtures/sample-project/src/types/a.ts +3 -0
  36. scip_cli-2.2.1/tests/fixtures/sample-project/src/types/b.ts +3 -0
  37. scip_cli-2.2.1/tests/fixtures/sample-project/src/ui/Button.ts +7 -0
  38. scip_cli-2.2.1/tests/fixtures/sample-project/src/ui/LazyPanel.ts +3 -0
  39. scip_cli-2.2.1/tests/fixtures/sample-project/src/ui/buttonConsumer.ts +5 -0
  40. scip_cli-2.2.1/tests/fixtures/sample-project/src/ui/menuModule.ts +1 -0
  41. scip_cli-2.2.1/tests/fixtures/sample-project/src/widgets/OrphanWidget.ts +3 -0
  42. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_analyze.py +65 -10
  43. scip_cli-2.2.1/tests/test_analyze_graph.py +66 -0
  44. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_e2e.py +2 -2
  45. scip_cli-2.2.1/tests/test_e2e_analyze_patterns.py +168 -0
  46. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_pure_functions.py +9 -1
  47. scip_cli-2.1.0/scip_cli/analyze/sections.py +0 -77
  48. scip_cli-2.1.0/scripts/publish.sh +0 -32
  49. scip_cli-2.1.0/tests/fixture_catalog.py +0 -17
  50. scip_cli-2.1.0/tests/test_analyze_graph.py +0 -30
  51. {scip_cli-2.1.0 → scip_cli-2.2.1}/.gitignore +0 -0
  52. {scip_cli-2.1.0 → scip_cli-2.2.1}/.pre-commit-config.yaml +0 -0
  53. {scip_cli-2.1.0 → scip_cli-2.2.1}/LICENSE +0 -0
  54. {scip_cli-2.1.0 → scip_cli-2.2.1}/pyproject.toml +0 -0
  55. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/analyze/__init__.py +0 -0
  56. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/analyze/targets.py +0 -0
  57. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/cache.py +0 -0
  58. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/cli_args.py +0 -0
  59. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/commands/__init__.py +0 -0
  60. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/commands/code.py +0 -0
  61. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/commands/members.py +0 -0
  62. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/commands/rdeps.py +0 -0
  63. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/commands/reindex.py +0 -0
  64. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/commands/search.py +0 -0
  65. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/commands/skill.py +0 -0
  66. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/commands/symbols.py +0 -0
  67. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/config.py +0 -0
  68. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/debug.py +0 -0
  69. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/discover.py +0 -0
  70. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/indexing.py +0 -0
  71. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/merge.py +0 -0
  72. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/paths.py +0 -0
  73. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/project.py +0 -0
  74. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/scip_tool.py +0 -0
  75. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/scope.py +0 -0
  76. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/session.py +0 -0
  77. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/source.py +0 -0
  78. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/sql.py +0 -0
  79. {scip_cli-2.1.0 → scip_cli-2.2.1}/scip_cli/targets.py +0 -0
  80. {scip_cli-2.1.0 → scip_cli-2.2.1}/scripts/build.sh +0 -0
  81. {scip_cli-2.1.0 → scip_cli-2.2.1}/scripts/test.sh +0 -0
  82. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/__init__.py +0 -0
  83. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/conftest.py +0 -0
  84. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/e2e_harness.py +0 -0
  85. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/package.json +0 -0
  86. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/app/handler.ts +0 -0
  87. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/config.ts +0 -0
  88. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/consumer.ts +0 -0
  89. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/dead.ts +0 -0
  90. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/helper.ts +0 -0
  91. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/index.ts +0 -0
  92. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/user.ts +0 -0
  93. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/src/widget.ts +0 -0
  94. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/fixtures/sample-project/tsconfig.json +0 -0
  95. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/perf_util.py +0 -0
  96. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_analyze_perf.py +0 -0
  97. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_cache.py +0 -0
  98. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_composability.py +0 -0
  99. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_config.py +0 -0
  100. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_discover.py +0 -0
  101. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_e2e_perf.py +0 -0
  102. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_index_prune.py +0 -0
  103. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_indexer_env.py +0 -0
  104. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_merge.py +0 -0
  105. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_multi_symbol.py +0 -0
  106. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_qualified_symbols.py +0 -0
  107. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_reindex.py +0 -0
  108. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_scip_tool.py +0 -0
  109. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_scope.py +0 -0
  110. {scip_cli-2.1.0 → scip_cli-2.2.1}/tests/test_targets.py +0 -0
  111. {scip_cli-2.1.0 → scip_cli-2.2.1}/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.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.1.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",
@@ -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"))
@@ -104,7 +127,7 @@ def cycle_path_noise(cycle_line: str, *, include_tests: bool = False) -> bool:
104
127
  return any(is_test_path(part.strip()) for part in parts if part.strip())
105
128
 
106
129
 
107
- 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]:
108
131
  if not lines:
109
- return title, ["(none)"]
110
- return title, lines
132
+ return title, ["(none)"], None
133
+ return title, lines, preface
@@ -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, file_has_scip_importers, has_same_file_reference_usage
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,18 +106,22 @@ 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
  """
118
- SELECT gs.symbol, der.start_line, der.end_line
124
+ SELECT gs.symbol, der.start_line, der.end_line, def_d.id
119
125
  FROM global_symbols gs
120
126
  JOIN defn_enclosing_ranges der ON gs.id = der.symbol_id
121
127
  JOIN documents def_d ON der.document_id = def_d.id
@@ -135,18 +141,24 @@ 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, def_doc_id in rows:
146
+ if analyze_noise(relative_path, symbol, include_tests=True):
147
+ continue
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):
151
+ continue
152
+ lines.append(f"{short_name(symbol)} {start + 1}:{end + 1}")
153
+ return lines
143
154
 
144
155
 
145
156
  def dead_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str]:
157
+ live = LiveIndex(db)
146
158
  rows = fetch_all(
147
159
  db,
148
160
  """
149
- SELECT gs.symbol, der.start_line, der.end_line
161
+ SELECT gs.id, gs.symbol, der.start_line, der.end_line, def_d.id
150
162
  FROM global_symbols gs
151
163
  JOIN defn_enclosing_ranges der ON gs.id = der.symbol_id
152
164
  JOIN documents def_d ON der.document_id = def_d.id
@@ -164,11 +176,16 @@ def dead_in_file(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str
164
176
  """,
165
177
  (relative_path, limit),
166
178
  )
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
- ]
179
+ lines = []
180
+ for sym_id, symbol, start, end, def_doc_id in rows:
181
+ if analyze_noise(relative_path, symbol, include_tests=True):
182
+ continue
183
+ if has_same_file_reference_usage(db, sym_id, def_doc_id):
184
+ continue
185
+ if live.dead_export_noise(symbol, def_doc_id):
186
+ continue
187
+ lines.append(f"{short_name(symbol)} {start + 1}:{end + 1}")
188
+ return lines
172
189
 
173
190
 
174
191
  def imports_summary(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str]:
@@ -240,6 +257,16 @@ def coupling_for(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str
240
257
  return [f"{other} shared={shared}" for other, shared in rows]
241
258
 
242
259
 
260
+ def count_file_importers(db, relative_path: str) -> int:
261
+ from ..queries import get_file_symbols, get_importer_paths
262
+
263
+ symbols = get_file_symbols(db, relative_path)
264
+ if not symbols:
265
+ return 0
266
+ symbol_ids = [row[0] for row in symbols]
267
+ return len(get_importer_paths(db, symbol_ids, relative_path))
268
+
269
+
243
270
  def top_symbol_pressure(db, relative_path: str, limit: int = DEFAULT_LIMIT) -> list[str]:
244
271
  """Pressure metrics for the most-consumed exports in a file."""
245
272
  cap = min(5, limit)
@@ -321,8 +348,10 @@ def _run_file_checks(
321
348
  db,
322
349
  limit: int,
323
350
  priorities,
324
- ) -> list[tuple[str, list[str]]]:
325
- 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)
326
355
 
327
356
 
328
357
  def run_all(
@@ -330,12 +359,14 @@ def run_all(
330
359
  relative_path: str,
331
360
  limit: int = DEFAULT_LIMIT,
332
361
  priorities=None,
333
- ) -> list[tuple[str, list[str]]]:
362
+ budget=None,
363
+ ) -> list[tuple[str, list[str], str | None]]:
334
364
  return _run_file_checks(
335
365
  _file_checks(relative_path, include_top_symbols=True),
336
366
  db,
337
367
  limit,
338
368
  priorities,
369
+ budget=budget,
339
370
  )
340
371
 
341
372
 
@@ -344,11 +375,13 @@ def run_all_sections_only(
344
375
  relative_path: str,
345
376
  limit: int = DEFAULT_LIMIT,
346
377
  priorities=None,
347
- ) -> list[tuple[str, list[str]]]:
378
+ budget=None,
379
+ ) -> list[tuple[str, list[str], str | None]]:
348
380
  """Per-file sections without top-symbols (directory batch)."""
349
381
  return _run_file_checks(
350
382
  _file_checks(relative_path, include_top_symbols=False),
351
383
  db,
352
384
  limit,
353
385
  priorities,
386
+ budget=budget,
354
387
  )
@@ -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,168 @@
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.module_importers: dict[int, int] = {}
68
+ self._build(db)
69
+
70
+ def _build(self, db) -> None:
71
+ for doc_id, importer_count in _fetch_all(
72
+ db,
73
+ """
74
+ SELECT der.document_id, COUNT(DISTINCT c.document_id)
75
+ FROM global_symbols gs
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
79
+ WHERE gs.symbol LIKE '%/' AND gs.symbol NOT LIKE '%().'
80
+ AND c.document_id != der.document_id
81
+ GROUP BY der.document_id
82
+ """,
83
+ ):
84
+ self.live_module_docs.add(doc_id)
85
+ self.module_importers[doc_id] = importer_count
86
+
87
+ for sym_id, symbol in _fetch_all(
88
+ db,
89
+ """
90
+ SELECT id, symbol FROM global_symbols
91
+ WHERE symbol LIKE '%0:' AND symbol NOT LIKE '%().'
92
+ """,
93
+ ):
94
+ base = export_alias_base(symbol)
95
+ if not base:
96
+ continue
97
+ def_doc = _def_doc_id_from_symbol(db, symbol)
98
+ if def_doc is None:
99
+ continue
100
+ if _fetch_one(db, _EXTERNAL_MENTION, (sym_id, def_doc)):
101
+ self.live_alias_bases.add(base)
102
+
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)."""
105
+ if is_module_symbol(symbol):
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}"
110
+ base = export_value_base(symbol)
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
119
+
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:
125
+ """Export used only in-file but live via module or export alias elsewhere."""
126
+ base = export_value_base(symbol)
127
+ if not base:
128
+ return False
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
132
+
133
+ def stale_type_live_noise(self, symbol: str, def_doc_id: int) -> bool:
134
+ """Class/type looks unused but file is live via default export or module import."""
135
+ base = export_value_base(symbol)
136
+ if not base:
137
+ return False
138
+ if base in self.live_alias_bases:
139
+ return True
140
+ return def_doc_id in self.live_module_docs
141
+
142
+
143
+ def has_same_file_reference_usage(db, symbol_id: int, def_doc_id: int) -> bool:
144
+ """Type or value referenced in the same file (extends, signatures, handler registration)."""
145
+ row = _fetch_one(
146
+ db,
147
+ """
148
+ SELECT 1 FROM mentions m
149
+ JOIN chunks c ON m.chunk_id = c.id
150
+ WHERE m.symbol_id = ? AND m.role = 0 AND c.document_id = ?
151
+ LIMIT 1
152
+ """,
153
+ (symbol_id, def_doc_id),
154
+ )
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))