scip-query 0.1.0

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 (330) hide show
  1. package/IMPROVEMENTS.md +143 -0
  2. package/PLAN.md +320 -0
  3. package/README.md +1213 -0
  4. package/dist/chunk-2QZ23IBN.js +55 -0
  5. package/dist/chunk-2QZ23IBN.js.map +1 -0
  6. package/dist/chunk-36OMT7ZJ.js +144 -0
  7. package/dist/chunk-36OMT7ZJ.js.map +1 -0
  8. package/dist/chunk-3E2X7RIE.js +101 -0
  9. package/dist/chunk-3E2X7RIE.js.map +1 -0
  10. package/dist/chunk-3UOUTZQT.js +45 -0
  11. package/dist/chunk-3UOUTZQT.js.map +1 -0
  12. package/dist/chunk-3ZZJVBIO.js +88 -0
  13. package/dist/chunk-3ZZJVBIO.js.map +1 -0
  14. package/dist/chunk-4TYLS5XX.js +10 -0
  15. package/dist/chunk-4TYLS5XX.js.map +1 -0
  16. package/dist/chunk-5FGUEU7N.js +101 -0
  17. package/dist/chunk-5FGUEU7N.js.map +1 -0
  18. package/dist/chunk-5WTJAXY2.js +61 -0
  19. package/dist/chunk-5WTJAXY2.js.map +1 -0
  20. package/dist/chunk-6NBLIDF4.js +24 -0
  21. package/dist/chunk-6NBLIDF4.js.map +1 -0
  22. package/dist/chunk-6SXADWLW.js +43 -0
  23. package/dist/chunk-6SXADWLW.js.map +1 -0
  24. package/dist/chunk-6VJ6Q7IE.js +65 -0
  25. package/dist/chunk-6VJ6Q7IE.js.map +1 -0
  26. package/dist/chunk-7OZPA5OO.js +258 -0
  27. package/dist/chunk-7OZPA5OO.js.map +1 -0
  28. package/dist/chunk-BEPIEVLR.js +76 -0
  29. package/dist/chunk-BEPIEVLR.js.map +1 -0
  30. package/dist/chunk-BFSCMC22.js +42 -0
  31. package/dist/chunk-BFSCMC22.js.map +1 -0
  32. package/dist/chunk-BP2ATLK2.js +110 -0
  33. package/dist/chunk-BP2ATLK2.js.map +1 -0
  34. package/dist/chunk-CM454WL3.js +114 -0
  35. package/dist/chunk-CM454WL3.js.map +1 -0
  36. package/dist/chunk-DCKMSTJ4.js +74 -0
  37. package/dist/chunk-DCKMSTJ4.js.map +1 -0
  38. package/dist/chunk-DEZKCZXD.js +40 -0
  39. package/dist/chunk-DEZKCZXD.js.map +1 -0
  40. package/dist/chunk-DVWGWHFW.js +99 -0
  41. package/dist/chunk-DVWGWHFW.js.map +1 -0
  42. package/dist/chunk-EMDQWNYR.js +102 -0
  43. package/dist/chunk-EMDQWNYR.js.map +1 -0
  44. package/dist/chunk-FFSWWE5O.js +33 -0
  45. package/dist/chunk-FFSWWE5O.js.map +1 -0
  46. package/dist/chunk-FGXRVW7G.js +73 -0
  47. package/dist/chunk-FGXRVW7G.js.map +1 -0
  48. package/dist/chunk-FUHJCHS4.js +158 -0
  49. package/dist/chunk-FUHJCHS4.js.map +1 -0
  50. package/dist/chunk-GJFURBEW.js +64 -0
  51. package/dist/chunk-GJFURBEW.js.map +1 -0
  52. package/dist/chunk-GTILYBH6.js +102 -0
  53. package/dist/chunk-GTILYBH6.js.map +1 -0
  54. package/dist/chunk-JJP7KQND.js +1 -0
  55. package/dist/chunk-JJP7KQND.js.map +1 -0
  56. package/dist/chunk-JKP5GH6T.js +213 -0
  57. package/dist/chunk-JKP5GH6T.js.map +1 -0
  58. package/dist/chunk-KCBMVQL5.js +38 -0
  59. package/dist/chunk-KCBMVQL5.js.map +1 -0
  60. package/dist/chunk-KVSW5KYP.js +78 -0
  61. package/dist/chunk-KVSW5KYP.js.map +1 -0
  62. package/dist/chunk-LAWMH22O.js +172 -0
  63. package/dist/chunk-LAWMH22O.js.map +1 -0
  64. package/dist/chunk-LB7OS35Q.js +72 -0
  65. package/dist/chunk-LB7OS35Q.js.map +1 -0
  66. package/dist/chunk-LUSIFBXO.js +57 -0
  67. package/dist/chunk-LUSIFBXO.js.map +1 -0
  68. package/dist/chunk-MBVNHJVN.js +44 -0
  69. package/dist/chunk-MBVNHJVN.js.map +1 -0
  70. package/dist/chunk-MGNMHKX3.js +15 -0
  71. package/dist/chunk-MGNMHKX3.js.map +1 -0
  72. package/dist/chunk-N5KEREIA.js +41 -0
  73. package/dist/chunk-N5KEREIA.js.map +1 -0
  74. package/dist/chunk-NDSQYIWT.js +71 -0
  75. package/dist/chunk-NDSQYIWT.js.map +1 -0
  76. package/dist/chunk-NUZ4OMU3.js +28 -0
  77. package/dist/chunk-NUZ4OMU3.js.map +1 -0
  78. package/dist/chunk-QOV2R2WT.js +170 -0
  79. package/dist/chunk-QOV2R2WT.js.map +1 -0
  80. package/dist/chunk-SEFSL2GF.js +78 -0
  81. package/dist/chunk-SEFSL2GF.js.map +1 -0
  82. package/dist/chunk-T6ARFSBZ.js +103 -0
  83. package/dist/chunk-T6ARFSBZ.js.map +1 -0
  84. package/dist/chunk-TBP6BICL.js +46 -0
  85. package/dist/chunk-TBP6BICL.js.map +1 -0
  86. package/dist/chunk-TDNNOR6D.js +97 -0
  87. package/dist/chunk-TDNNOR6D.js.map +1 -0
  88. package/dist/chunk-TSPZOMHC.js +195 -0
  89. package/dist/chunk-TSPZOMHC.js.map +1 -0
  90. package/dist/chunk-UNTPVD36.js +55 -0
  91. package/dist/chunk-UNTPVD36.js.map +1 -0
  92. package/dist/chunk-VRUJH4BO.js +88 -0
  93. package/dist/chunk-VRUJH4BO.js.map +1 -0
  94. package/dist/chunk-VZ7AMAFL.js +76 -0
  95. package/dist/chunk-VZ7AMAFL.js.map +1 -0
  96. package/dist/chunk-XFXDXEUN.js +24 -0
  97. package/dist/chunk-XFXDXEUN.js.map +1 -0
  98. package/dist/chunk-YZAA4LYG.js +169 -0
  99. package/dist/chunk-YZAA4LYG.js.map +1 -0
  100. package/dist/chunk-Z73NYSBZ.js +92 -0
  101. package/dist/chunk-Z73NYSBZ.js.map +1 -0
  102. package/dist/chunk-ZJRYBOEE.js +125 -0
  103. package/dist/chunk-ZJRYBOEE.js.map +1 -0
  104. package/dist/cli.js +5798 -0
  105. package/dist/cli.js.map +1 -0
  106. package/dist/db-BxaevAyc.d.ts +683 -0
  107. package/dist/index.d.ts +254 -0
  108. package/dist/index.js +1271 -0
  109. package/dist/index.js.map +1 -0
  110. package/dist/postinstall.js +167 -0
  111. package/dist/postinstall.js.map +1 -0
  112. package/dist/queries/affected.d.ts +14 -0
  113. package/dist/queries/affected.js +9 -0
  114. package/dist/queries/affected.js.map +1 -0
  115. package/dist/queries/bottlenecks.d.ts +18 -0
  116. package/dist/queries/bottlenecks.js +8 -0
  117. package/dist/queries/bottlenecks.js.map +1 -0
  118. package/dist/queries/by-kind.d.ts +20 -0
  119. package/dist/queries/by-kind.js +10 -0
  120. package/dist/queries/by-kind.js.map +1 -0
  121. package/dist/queries/call-graph.d.ts +13 -0
  122. package/dist/queries/call-graph.js +9 -0
  123. package/dist/queries/call-graph.js.map +1 -0
  124. package/dist/queries/change-surface.d.ts +10 -0
  125. package/dist/queries/change-surface.js +9 -0
  126. package/dist/queries/change-surface.js.map +1 -0
  127. package/dist/queries/clean-signature.d.ts +9 -0
  128. package/dist/queries/clean-signature.js +7 -0
  129. package/dist/queries/clean-signature.js.map +1 -0
  130. package/dist/queries/code.d.ts +17 -0
  131. package/dist/queries/code.js +9 -0
  132. package/dist/queries/code.js.map +1 -0
  133. package/dist/queries/complexity-hotspots.d.ts +19 -0
  134. package/dist/queries/complexity-hotspots.js +9 -0
  135. package/dist/queries/complexity-hotspots.js.map +1 -0
  136. package/dist/queries/complexity.d.ts +13 -0
  137. package/dist/queries/complexity.js +9 -0
  138. package/dist/queries/complexity.js.map +1 -0
  139. package/dist/queries/convergence.d.ts +11 -0
  140. package/dist/queries/convergence.js +9 -0
  141. package/dist/queries/convergence.js.map +1 -0
  142. package/dist/queries/coupling.d.ts +17 -0
  143. package/dist/queries/coupling.js +9 -0
  144. package/dist/queries/coupling.js.map +1 -0
  145. package/dist/queries/cycles.d.ts +16 -0
  146. package/dist/queries/cycles.js +8 -0
  147. package/dist/queries/cycles.js.map +1 -0
  148. package/dist/queries/dataflow.d.ts +19 -0
  149. package/dist/queries/dataflow.js +9 -0
  150. package/dist/queries/dataflow.js.map +1 -0
  151. package/dist/queries/dead.d.ts +10 -0
  152. package/dist/queries/dead.js +9 -0
  153. package/dist/queries/dead.js.map +1 -0
  154. package/dist/queries/deep-chains.d.ts +16 -0
  155. package/dist/queries/deep-chains.js +8 -0
  156. package/dist/queries/deep-chains.js.map +1 -0
  157. package/dist/queries/deps.d.ts +9 -0
  158. package/dist/queries/deps.js +9 -0
  159. package/dist/queries/deps.js.map +1 -0
  160. package/dist/queries/diff-impact.d.ts +13 -0
  161. package/dist/queries/diff-impact.js +9 -0
  162. package/dist/queries/diff-impact.js.map +1 -0
  163. package/dist/queries/doc-coverage.d.ts +14 -0
  164. package/dist/queries/doc-coverage.js +8 -0
  165. package/dist/queries/doc-coverage.js.map +1 -0
  166. package/dist/queries/drift.d.ts +25 -0
  167. package/dist/queries/drift.js +8 -0
  168. package/dist/queries/drift.js.map +1 -0
  169. package/dist/queries/extract-candidates.d.ts +25 -0
  170. package/dist/queries/extract-candidates.js +9 -0
  171. package/dist/queries/extract-candidates.js.map +1 -0
  172. package/dist/queries/fan.d.ts +29 -0
  173. package/dist/queries/fan.js +14 -0
  174. package/dist/queries/fan.js.map +1 -0
  175. package/dist/queries/files.d.ts +6 -0
  176. package/dist/queries/files.js +7 -0
  177. package/dist/queries/files.js.map +1 -0
  178. package/dist/queries/health.d.ts +18 -0
  179. package/dist/queries/health.js +21 -0
  180. package/dist/queries/health.js.map +1 -0
  181. package/dist/queries/hierarchy.d.ts +13 -0
  182. package/dist/queries/hierarchy.js +8 -0
  183. package/dist/queries/hierarchy.js.map +1 -0
  184. package/dist/queries/hotspots.d.ts +13 -0
  185. package/dist/queries/hotspots.js +8 -0
  186. package/dist/queries/hotspots.js.map +1 -0
  187. package/dist/queries/imports.d.ts +19 -0
  188. package/dist/queries/imports.js +12 -0
  189. package/dist/queries/imports.js.map +1 -0
  190. package/dist/queries/index.d.ts +47 -0
  191. package/dist/queries/index.js +207 -0
  192. package/dist/queries/index.js.map +1 -0
  193. package/dist/queries/isolated.d.ts +14 -0
  194. package/dist/queries/isolated.js +9 -0
  195. package/dist/queries/isolated.js.map +1 -0
  196. package/dist/queries/members.d.ts +10 -0
  197. package/dist/queries/members.js +8 -0
  198. package/dist/queries/members.js.map +1 -0
  199. package/dist/queries/methods.d.ts +6 -0
  200. package/dist/queries/methods.js +8 -0
  201. package/dist/queries/methods.js.map +1 -0
  202. package/dist/queries/outline.d.ts +10 -0
  203. package/dist/queries/outline.js +8 -0
  204. package/dist/queries/outline.js.map +1 -0
  205. package/dist/queries/passthrough-candidates.d.ts +18 -0
  206. package/dist/queries/passthrough-candidates.js +9 -0
  207. package/dist/queries/passthrough-candidates.js.map +1 -0
  208. package/dist/queries/redundant-reexports.d.ts +22 -0
  209. package/dist/queries/redundant-reexports.js +8 -0
  210. package/dist/queries/redundant-reexports.js.map +1 -0
  211. package/dist/queries/refs.d.ts +6 -0
  212. package/dist/queries/refs.js +7 -0
  213. package/dist/queries/refs.js.map +1 -0
  214. package/dist/queries/similar-chains.d.ts +29 -0
  215. package/dist/queries/similar-chains.js +8 -0
  216. package/dist/queries/similar-chains.js.map +1 -0
  217. package/dist/queries/similar-files.d.ts +19 -0
  218. package/dist/queries/similar-files.js +8 -0
  219. package/dist/queries/similar-files.js.map +1 -0
  220. package/dist/queries/similar-signatures.d.ts +21 -0
  221. package/dist/queries/similar-signatures.js +8 -0
  222. package/dist/queries/similar-signatures.js.map +1 -0
  223. package/dist/queries/similar.d.ts +34 -0
  224. package/dist/queries/similar.js +11 -0
  225. package/dist/queries/similar.js.map +1 -0
  226. package/dist/queries/slice.d.ts +21 -0
  227. package/dist/queries/slice.js +9 -0
  228. package/dist/queries/slice.js.map +1 -0
  229. package/dist/queries/stale-abstractions.d.ts +18 -0
  230. package/dist/queries/stale-abstractions.js +9 -0
  231. package/dist/queries/stale-abstractions.js.map +1 -0
  232. package/dist/queries/stats.d.ts +6 -0
  233. package/dist/queries/stats.js +7 -0
  234. package/dist/queries/stats.js.map +1 -0
  235. package/dist/queries/surface.d.ts +7 -0
  236. package/dist/queries/surface.js +8 -0
  237. package/dist/queries/surface.js.map +1 -0
  238. package/dist/queries/symbols.d.ts +6 -0
  239. package/dist/queries/symbols.js +9 -0
  240. package/dist/queries/symbols.js.map +1 -0
  241. package/dist/queries/system.d.ts +7 -0
  242. package/dist/queries/system.js +9 -0
  243. package/dist/queries/system.js.map +1 -0
  244. package/dist/queries/test-coverage.d.ts +22 -0
  245. package/dist/queries/test-coverage.js +11 -0
  246. package/dist/queries/test-coverage.js.map +1 -0
  247. package/dist/queries/trace.d.ts +6 -0
  248. package/dist/queries/trace.js +8 -0
  249. package/dist/queries/trace.js.map +1 -0
  250. package/dist/queries/wrapper-candidates.d.ts +17 -0
  251. package/dist/queries/wrapper-candidates.js +9 -0
  252. package/dist/queries/wrapper-candidates.js.map +1 -0
  253. package/dist/reindex-worker.js +368 -0
  254. package/dist/reindex-worker.js.map +1 -0
  255. package/docs/AGENT_GUIDE.md +359 -0
  256. package/package.json +70 -0
  257. package/reports/debloat/2026-04-10-scip-query-self-audit.md +161 -0
  258. package/skills/concrete-plan/SKILL.md +318 -0
  259. package/skills/scip-debloat/SKILL.md +413 -0
  260. package/skills/scip-explore/SKILL.md +235 -0
  261. package/skills/scip-verify/SKILL.md +323 -0
  262. package/src/cli.ts +1480 -0
  263. package/src/config.ts +117 -0
  264. package/src/db.ts +127 -0
  265. package/src/gitignore-filter.ts +143 -0
  266. package/src/index.ts +11 -0
  267. package/src/postinstall.ts +8 -0
  268. package/src/queries/affected.ts +86 -0
  269. package/src/queries/bottlenecks.ts +67 -0
  270. package/src/queries/by-kind.ts +204 -0
  271. package/src/queries/call-graph.ts +66 -0
  272. package/src/queries/change-surface.ts +110 -0
  273. package/src/queries/clean-signature.ts +22 -0
  274. package/src/queries/code.ts +101 -0
  275. package/src/queries/complexity-hotspots.ts +119 -0
  276. package/src/queries/complexity.ts +152 -0
  277. package/src/queries/convergence.ts +82 -0
  278. package/src/queries/coupling.ts +99 -0
  279. package/src/queries/cycles.ts +78 -0
  280. package/src/queries/dataflow.ts +128 -0
  281. package/src/queries/dead.ts +122 -0
  282. package/src/queries/deep-chains.ts +59 -0
  283. package/src/queries/deps.ts +46 -0
  284. package/src/queries/diff-impact.ts +204 -0
  285. package/src/queries/doc-coverage.ts +86 -0
  286. package/src/queries/drift.ts +224 -0
  287. package/src/queries/extract-candidates.ts +167 -0
  288. package/src/queries/fan.ts +148 -0
  289. package/src/queries/files.ts +16 -0
  290. package/src/queries/health.ts +324 -0
  291. package/src/queries/hierarchy.ts +49 -0
  292. package/src/queries/hotspots.ts +53 -0
  293. package/src/queries/imports.ts +95 -0
  294. package/src/queries/index.ts +45 -0
  295. package/src/queries/isolated.ts +67 -0
  296. package/src/queries/members.ts +54 -0
  297. package/src/queries/methods.ts +27 -0
  298. package/src/queries/outline.ts +52 -0
  299. package/src/queries/passthrough-candidates.ts +94 -0
  300. package/src/queries/redundant-reexports.ts +170 -0
  301. package/src/queries/refs.ts +27 -0
  302. package/src/queries/similar-chains.ts +314 -0
  303. package/src/queries/similar-files.ts +140 -0
  304. package/src/queries/similar-signatures.ts +151 -0
  305. package/src/queries/similar.ts +305 -0
  306. package/src/queries/slice.ts +154 -0
  307. package/src/queries/stale-abstractions.ts +82 -0
  308. package/src/queries/stats.ts +22 -0
  309. package/src/queries/surface.ts +34 -0
  310. package/src/queries/symbols.ts +39 -0
  311. package/src/queries/system.ts +86 -0
  312. package/src/queries/test-coverage.ts +106 -0
  313. package/src/queries/trace.ts +55 -0
  314. package/src/queries/wrapper-candidates.ts +112 -0
  315. package/src/query-support.ts +226 -0
  316. package/src/reindex/detect.ts +58 -0
  317. package/src/reindex/index.ts +153 -0
  318. package/src/reindex/indexers.ts +220 -0
  319. package/src/reindex/install.ts +125 -0
  320. package/src/reindex-worker.ts +35 -0
  321. package/src/setup.ts +202 -0
  322. package/src/symbol-parser.ts +278 -0
  323. package/src/types.ts +654 -0
  324. package/src/watch.ts +274 -0
  325. package/tests/gitignore-filter.test.ts +48 -0
  326. package/tests/queries.test.ts +300 -0
  327. package/tests/symbol-parser.test.ts +157 -0
  328. package/tsconfig.json +20 -0
  329. package/tsup.config.ts +40 -0
  330. package/vitest.config.ts +7 -0
package/src/watch.ts ADDED
@@ -0,0 +1,274 @@
1
+ import { watch } from 'node:fs';
2
+ import { readFileSync, existsSync, renameSync } from 'node:fs';
3
+ import { join, relative } from 'node:path';
4
+ import { fork } from 'node:child_process';
5
+ import ignore from 'ignore';
6
+ import type { WatcherStatus, ProjectConfig, SupportedLanguage } from './types.js';
7
+ import { resolveWatchConfig, resolveIndexPaths } from './config.js';
8
+ import { createGitignoreFilter } from './gitignore-filter.js';
9
+
10
+ export interface WatcherOptions {
11
+ projectRoot: string;
12
+ config: ProjectConfig;
13
+ languages?: SupportedLanguage[];
14
+ onStatus?: (status: WatcherStatus) => void;
15
+ onReindexComplete?: (durationMs: number) => void;
16
+ onError?: (error: Error) => void;
17
+ }
18
+
19
+ /**
20
+ * File watcher that triggers single-flight background reindexing.
21
+ *
22
+ * Design:
23
+ * - Debounce: waits 30s (configurable) after the last file change
24
+ * - Single-flight: only one reindex runs at a time, never queued
25
+ * - Dirty flag: changes during reindex schedule ONE follow-up
26
+ * - Cooldown: minimum interval between reindex completions
27
+ * - Atomic swap: writes to index.db.tmp, renames on success
28
+ */
29
+ export class Watcher {
30
+ private projectRoot: string;
31
+ private watchConfig: Required<NonNullable<ProjectConfig['watch']>>;
32
+ private indexPaths: ReturnType<typeof resolveIndexPaths>;
33
+ private languages?: SupportedLanguage[];
34
+ private pnpmWorkspaces: boolean;
35
+
36
+ private onStatus: (status: WatcherStatus) => void;
37
+ private onReindexComplete: (durationMs: number) => void;
38
+ private onError: (error: Error) => void;
39
+
40
+ // State machine
41
+ private status: WatcherStatus = { state: 'idle' };
42
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
43
+ private cooldownTimer: ReturnType<typeof setTimeout> | null = null;
44
+ private dirty = false;
45
+ private changedFiles = 0;
46
+ private reindexInFlight = false;
47
+ private lastReindexEnd = 0;
48
+
49
+ // fs.watch watchers (one per watched directory)
50
+ private fsWatchers: ReturnType<typeof watch>[] = [];
51
+ private gitignoreFilter: ReturnType<typeof createGitignoreFilter>;
52
+ private extraIgnore: ReturnType<typeof ignore>;
53
+ private stopped = false;
54
+
55
+ constructor(opts: WatcherOptions) {
56
+ this.projectRoot = opts.projectRoot;
57
+ this.watchConfig = resolveWatchConfig(opts.config);
58
+ this.indexPaths = resolveIndexPaths(opts.projectRoot, opts.config);
59
+ this.languages = opts.languages;
60
+ this.pnpmWorkspaces = opts.config.indexer?.typescript?.pnpmWorkspaces ?? false;
61
+
62
+ this.onStatus = opts.onStatus ?? (() => {});
63
+ this.onReindexComplete = opts.onReindexComplete ?? (() => {});
64
+ this.onError = opts.onError ?? ((e) => console.error(e.message));
65
+
66
+ this.gitignoreFilter = createGitignoreFilter(opts.projectRoot);
67
+ this.extraIgnore = ignore();
68
+ if (this.watchConfig.ignore.length > 0) {
69
+ this.extraIgnore.add(this.watchConfig.ignore);
70
+ }
71
+ }
72
+
73
+ /** Start watching for file changes */
74
+ start(): void {
75
+ this.stopped = false;
76
+ this.setStatus({ state: 'idle' });
77
+
78
+ // Use recursive fs.watch on the project root
79
+ // This is supported on macOS (FSEvents) and Windows
80
+ // On Linux, falls back to inotify (may need per-directory watchers for large trees)
81
+ try {
82
+ const watcher = watch(
83
+ this.projectRoot,
84
+ { recursive: true },
85
+ (_event, filename) => {
86
+ if (filename && !this.stopped) {
87
+ this.handleFileChange(filename);
88
+ }
89
+ },
90
+ );
91
+ this.fsWatchers.push(watcher);
92
+ } catch {
93
+ this.onError(new Error(
94
+ 'Failed to start file watcher. On Linux, you may need to increase inotify limits: ' +
95
+ 'sysctl -w fs.inotify.max_user_watches=524288',
96
+ ));
97
+ }
98
+ }
99
+
100
+ /** Stop watching and clean up */
101
+ stop(): void {
102
+ this.stopped = true;
103
+ for (const w of this.fsWatchers) w.close();
104
+ this.fsWatchers = [];
105
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
106
+ if (this.cooldownTimer) clearTimeout(this.cooldownTimer);
107
+ this.setStatus({ state: 'idle' });
108
+ }
109
+
110
+ /** Get current watcher status */
111
+ getStatus(): WatcherStatus {
112
+ return this.status;
113
+ }
114
+
115
+ // ── Internal ─────────────────────────────────────────────
116
+
117
+ private handleFileChange(filename: string): void {
118
+ // Filter: skip gitignored files and extra ignore patterns
119
+ const rel = relative(this.projectRoot, join(this.projectRoot, filename));
120
+ if (this.gitignoreFilter.isIgnored(rel)) return;
121
+ if (this.extraIgnore.ignores(rel)) return;
122
+
123
+ // Skip the index files themselves
124
+ if (filename.endsWith('index.db') || filename.endsWith('index.scip') ||
125
+ filename.endsWith('index.db.tmp') || filename.endsWith('.scipquery.json')) {
126
+ return;
127
+ }
128
+
129
+ this.changedFiles++;
130
+
131
+ if (this.reindexInFlight) {
132
+ // Reindex is running — just mark dirty, don't schedule anything
133
+ this.dirty = true;
134
+ this.setStatus({
135
+ state: 'indexing',
136
+ startedAt: (this.status as { startedAt: number }).startedAt,
137
+ });
138
+ return;
139
+ }
140
+
141
+ if (this.status.state === 'cooldown') {
142
+ // In cooldown — mark dirty, the cooldown handler will pick it up
143
+ this.dirty = true;
144
+ this.setStatus({ state: 'cooldown', until: (this.status as { until: number }).until, dirty: true });
145
+ return;
146
+ }
147
+
148
+ // Reset the debounce timer — every new change pushes the trigger out
149
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
150
+
151
+ const reindexAt = Date.now() + this.watchConfig.debounceMs;
152
+ this.setStatus({ state: 'waiting', changedFiles: this.changedFiles, reindexAt });
153
+
154
+ this.debounceTimer = setTimeout(() => {
155
+ this.debounceTimer = null;
156
+ this.triggerReindex();
157
+ }, this.watchConfig.debounceMs);
158
+ }
159
+
160
+ private triggerReindex(): void {
161
+ if (this.reindexInFlight || this.stopped) return;
162
+
163
+ // Check cooldown
164
+ const timeSinceLastReindex = Date.now() - this.lastReindexEnd;
165
+ if (this.lastReindexEnd > 0 && timeSinceLastReindex < this.watchConfig.cooldownMs) {
166
+ const remaining = this.watchConfig.cooldownMs - timeSinceLastReindex;
167
+ this.dirty = true;
168
+ const until = Date.now() + remaining;
169
+ this.setStatus({ state: 'cooldown', until, dirty: true });
170
+
171
+ this.cooldownTimer = setTimeout(() => {
172
+ this.cooldownTimer = null;
173
+ if (this.dirty && !this.stopped) {
174
+ this.dirty = false;
175
+ this.triggerReindex();
176
+ }
177
+ }, remaining);
178
+ return;
179
+ }
180
+
181
+ this.reindexInFlight = true;
182
+ this.dirty = false;
183
+ this.changedFiles = 0;
184
+ const startedAt = Date.now();
185
+ this.setStatus({ state: 'indexing', startedAt });
186
+
187
+ // Run reindex in a child process so it doesn't block the watcher
188
+ this.runReindex()
189
+ .then((durationMs) => {
190
+ this.reindexInFlight = false;
191
+ this.lastReindexEnd = Date.now();
192
+ this.onReindexComplete(durationMs);
193
+
194
+ if (this.dirty && !this.stopped) {
195
+ // Changes arrived during reindex — enter cooldown then reindex again
196
+ const until = Date.now() + this.watchConfig.cooldownMs;
197
+ this.setStatus({ state: 'cooldown', until, dirty: true });
198
+
199
+ this.cooldownTimer = setTimeout(() => {
200
+ this.cooldownTimer = null;
201
+ if (this.dirty && !this.stopped) {
202
+ this.dirty = false;
203
+ this.triggerReindex();
204
+ } else {
205
+ this.setStatus({ state: 'idle' });
206
+ }
207
+ }, this.watchConfig.cooldownMs);
208
+ } else {
209
+ this.setStatus({ state: 'idle' });
210
+ }
211
+ })
212
+ .catch((err) => {
213
+ this.reindexInFlight = false;
214
+ this.lastReindexEnd = Date.now();
215
+ this.onError(err instanceof Error ? err : new Error(String(err)));
216
+ this.setStatus({ state: 'idle' });
217
+ });
218
+ }
219
+
220
+ /**
221
+ * Run the reindex in a forked child process.
222
+ * Writes to index.db.tmp, then atomically renames to index.db.
223
+ */
224
+ private runReindex(): Promise<number> {
225
+ return new Promise((resolve, reject) => {
226
+ const start = Date.now();
227
+ const tmpDb = this.indexPaths.dbPath + '.tmp';
228
+ const tmpScip = this.indexPaths.indexPath + '.tmp';
229
+
230
+ // Fork a child that runs the reindex
231
+ const child = fork(
232
+ new URL('./reindex-worker.js', import.meta.url).pathname,
233
+ [],
234
+ {
235
+ env: {
236
+ ...process.env,
237
+ SCIP_REINDEX_PROJECT_ROOT: this.projectRoot,
238
+ SCIP_REINDEX_OUTPUT_SCIP: tmpScip,
239
+ SCIP_REINDEX_OUTPUT_DB: tmpDb,
240
+ SCIP_REINDEX_LANGUAGES: this.languages?.join(',') ?? '',
241
+ SCIP_REINDEX_PNPM_WORKSPACES: this.pnpmWorkspaces ? '1' : '',
242
+ },
243
+ stdio: 'pipe',
244
+ },
245
+ );
246
+
247
+ child.on('exit', (code) => {
248
+ if (code === 0) {
249
+ // Atomic swap
250
+ try {
251
+ if (existsSync(tmpDb)) {
252
+ renameSync(tmpDb, this.indexPaths.dbPath);
253
+ }
254
+ if (existsSync(tmpScip)) {
255
+ renameSync(tmpScip, this.indexPaths.indexPath);
256
+ }
257
+ resolve(Date.now() - start);
258
+ } catch (err) {
259
+ reject(new Error(`Atomic swap failed: ${err}`));
260
+ }
261
+ } else {
262
+ reject(new Error(`Reindex worker exited with code ${code}`));
263
+ }
264
+ });
265
+
266
+ child.on('error', reject);
267
+ });
268
+ }
269
+
270
+ private setStatus(status: WatcherStatus): void {
271
+ this.status = status;
272
+ this.onStatus(status);
273
+ }
274
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createGitignoreFilter } from '../src/gitignore-filter.js';
3
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ describe('createGitignoreFilter', () => {
8
+ it('respects .gitignore patterns', () => {
9
+ const dir = mkdtempSync(join(tmpdir(), 'scip-query-test-'));
10
+ writeFileSync(join(dir, '.gitignore'), 'node_modules/\ndist/\n*.pyc\n');
11
+
12
+ const filter = createGitignoreFilter(dir);
13
+
14
+ expect(filter.isIgnored('node_modules/foo/bar.js')).toBe(true);
15
+ expect(filter.isIgnored('dist/index.js')).toBe(true);
16
+ expect(filter.isIgnored('foo.pyc')).toBe(true);
17
+ expect(filter.isIgnored('src/app.ts')).toBe(false);
18
+ expect(filter.isIgnored('lib/utils.py')).toBe(false);
19
+ });
20
+
21
+ it('uses defaults when no .gitignore exists', () => {
22
+ const dir = mkdtempSync(join(tmpdir(), 'scip-query-test-'));
23
+
24
+ const filter = createGitignoreFilter(dir);
25
+
26
+ // Defaults should exclude common dependency/build dirs
27
+ expect(filter.isIgnored('node_modules/foo.js')).toBe(true);
28
+ expect(filter.isIgnored('dist/bundle.js')).toBe(true);
29
+ expect(filter.isIgnored('target/debug/main')).toBe(true);
30
+ expect(filter.isIgnored('__pycache__/foo.pyc')).toBe(true);
31
+ expect(filter.isIgnored('.venv/lib/site-packages/foo.py')).toBe(true);
32
+
33
+ // Source files should NOT be excluded
34
+ expect(filter.isIgnored('src/main.rs')).toBe(false);
35
+ expect(filter.isIgnored('app/models/user.rb')).toBe(false);
36
+ });
37
+
38
+ it('filters an array of paths', () => {
39
+ const dir = mkdtempSync(join(tmpdir(), 'scip-query-test-'));
40
+ writeFileSync(join(dir, '.gitignore'), 'node_modules/\n');
41
+
42
+ const filter = createGitignoreFilter(dir);
43
+ const paths = ['src/app.ts', 'node_modules/foo/index.js', 'lib/utils.ts'];
44
+ const filtered = filter.filter(paths);
45
+
46
+ expect(filtered).toEqual(['src/app.ts', 'lib/utils.ts']);
47
+ });
48
+ });
@@ -0,0 +1,300 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import Database from 'better-sqlite3';
3
+ import { mkdtempSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { ScipDatabase } from '../src/db.js';
7
+ import * as queries from '../src/queries/index.js';
8
+ import type { ScipQueryConfig } from '../src/types.js';
9
+
10
+ /**
11
+ * Creates a minimal SCIP SQLite database with fixture data
12
+ * that exercises all query commands. This simulates what
13
+ * `scip expt-convert` produces, but with controlled test data.
14
+ *
15
+ * NOTE: db.exec() below is better-sqlite3's SQL execution method,
16
+ * NOT child_process.exec(). No shell commands are being run.
17
+ */
18
+ function createFixtureDb(dbPath: string): void {
19
+ const sqliteDb = new Database(dbPath);
20
+ const run = (sql: string) => sqliteDb.exec(sql); // eslint-disable-line -- sqlite exec, not child_process
21
+
22
+ // Create the SCIP SQLite schema
23
+ run(`
24
+ CREATE TABLE documents (
25
+ id INTEGER PRIMARY KEY,
26
+ language TEXT,
27
+ relative_path TEXT NOT NULL UNIQUE,
28
+ position_encoding TEXT,
29
+ text TEXT
30
+ );
31
+ CREATE TABLE global_symbols (
32
+ id INTEGER PRIMARY KEY,
33
+ symbol TEXT NOT NULL UNIQUE,
34
+ display_name TEXT,
35
+ kind INTEGER,
36
+ documentation TEXT,
37
+ signature BLOB,
38
+ enclosing_symbol TEXT,
39
+ relationships BLOB
40
+ );
41
+ CREATE TABLE defn_enclosing_ranges (
42
+ id INTEGER PRIMARY KEY,
43
+ document_id INTEGER NOT NULL,
44
+ symbol_id INTEGER NOT NULL,
45
+ start_line INTEGER NOT NULL,
46
+ start_char INTEGER NOT NULL,
47
+ end_line INTEGER NOT NULL,
48
+ end_char INTEGER NOT NULL
49
+ );
50
+ CREATE TABLE mentions (
51
+ chunk_id INTEGER NOT NULL,
52
+ symbol_id INTEGER NOT NULL,
53
+ role INTEGER NOT NULL,
54
+ PRIMARY KEY (chunk_id, symbol_id, role)
55
+ );
56
+ CREATE TABLE chunks (
57
+ id INTEGER PRIMARY KEY,
58
+ document_id INTEGER NOT NULL,
59
+ chunk_index INTEGER NOT NULL,
60
+ start_line INTEGER NOT NULL,
61
+ end_line INTEGER NOT NULL,
62
+ occurrences BLOB NOT NULL
63
+ );
64
+ CREATE INDEX idx_mentions_symbol_id_role ON mentions(symbol_id, role);
65
+ CREATE INDEX idx_defn_enclosing_ranges_symbol_id ON defn_enclosing_ranges(symbol_id);
66
+ CREATE INDEX idx_defn_enclosing_ranges_document ON defn_enclosing_ranges(document_id, start_line, end_line);
67
+ CREATE INDEX idx_chunks_doc_id ON chunks(document_id);
68
+ CREATE INDEX idx_global_symbols_symbol ON global_symbols(symbol);
69
+ `);
70
+
71
+ // Insert test documents
72
+ run(`
73
+ INSERT INTO documents (id, language, relative_path) VALUES
74
+ (1, 'typescript', 'src/services/auth.service.ts'),
75
+ (2, 'typescript', 'src/services/user.service.ts'),
76
+ (3, 'typescript', 'src/controllers/auth.controller.ts'),
77
+ (4, 'typescript', 'src/__tests__/auth.test.ts'),
78
+ (5, 'python', 'lib/utils.py'),
79
+ (6, 'python', 'lib/models.py'),
80
+ (7, 'rust', 'src/main.rs');
81
+ `);
82
+
83
+ // Insert test symbols
84
+ const insertSymbol = sqliteDb.prepare(
85
+ `INSERT INTO global_symbols (id, symbol, display_name, kind, documentation) VALUES (?, ?, ?, ?, ?)`
86
+ );
87
+ insertSymbol.run(1, "scip-typescript npm my-app 1.0.0 src/services/`auth.service.ts`/AuthService#", 'AuthService', 1, 'AuthService class|class AuthService');
88
+ insertSymbol.run(2, "scip-typescript npm my-app 1.0.0 src/services/`auth.service.ts`/AuthService#login().", 'login', 2, "Login method|```ts\n(method) login(email: string): Promise<Token>\n```");
89
+ insertSymbol.run(3, "scip-typescript npm my-app 1.0.0 src/services/`auth.service.ts`/AuthService#logout().", 'logout', 2, "Logout method|```ts\n(method) logout(): void\n```");
90
+ insertSymbol.run(4, "scip-typescript npm my-app 1.0.0 src/services/`user.service.ts`/UserService#", 'UserService', 1, 'User service|class UserService');
91
+ insertSymbol.run(5, "scip-typescript npm my-app 1.0.0 src/services/`user.service.ts`/UserService#findById().", 'findById', 2, "Find by ID|```ts\n(method) findById(id: string): Promise<User>\n```");
92
+ insertSymbol.run(6, "scip-typescript npm my-app 1.0.0 src/services/`user.service.ts`/deadExport.", 'deadExport', 3, 'Not used anywhere|function deadExport(): void');
93
+ insertSymbol.run(7, "scip-python pip my-lib 1.0.0 lib/`utils.py`/format_name.", 'format_name', 3, 'Format name|def format_name(name: str) -> str');
94
+ insertSymbol.run(8, "rust-analyzer cargo my-crate 0.1.0 src/`main.rs`/Config#", 'Config', 1, 'Config struct|struct Config');
95
+
96
+ // Insert definition ranges
97
+ run(`
98
+ INSERT INTO defn_enclosing_ranges (id, document_id, symbol_id, start_line, start_char, end_line, end_char) VALUES
99
+ (1, 1, 1, 1, 0, 50, 1),
100
+ (2, 1, 2, 5, 2, 20, 3),
101
+ (3, 1, 3, 22, 2, 35, 3),
102
+ (4, 2, 4, 1, 0, 40, 1),
103
+ (5, 2, 5, 5, 2, 25, 3),
104
+ (6, 2, 6, 27, 2, 45, 3),
105
+ (7, 5, 7, 10, 0, 25, 0),
106
+ (8, 7, 8, 1, 0, 15, 1);
107
+ `);
108
+
109
+ // Insert chunks
110
+ run(`
111
+ INSERT INTO chunks (id, document_id, chunk_index, start_line, end_line, occurrences) VALUES
112
+ (1, 1, 0, 0, 50, X'00'),
113
+ (2, 2, 0, 0, 45, X'00'),
114
+ (3, 3, 0, 0, 30, X'00'),
115
+ (4, 4, 0, 0, 20, X'00'),
116
+ (5, 5, 0, 0, 30, X'00'),
117
+ (6, 6, 0, 0, 20, X'00'),
118
+ (7, 7, 0, 0, 20, X'00');
119
+ `);
120
+
121
+ // Insert mentions (role: 0 = reference, 1 = definition)
122
+ run(`
123
+ INSERT INTO mentions (chunk_id, symbol_id, role) VALUES
124
+ (1, 2, 1), (3, 2, 0), (4, 2, 0),
125
+ (1, 3, 1), (3, 3, 0),
126
+ (2, 5, 1), (1, 5, 0),
127
+ (2, 6, 1),
128
+ (5, 7, 1), (6, 7, 0),
129
+ (7, 8, 1),
130
+ (1, 1, 1), (3, 1, 0),
131
+ (2, 4, 1), (1, 4, 0);
132
+ `);
133
+
134
+ sqliteDb.close();
135
+ }
136
+
137
+ // ── Test Suite ──────────────────────────────────────────────
138
+
139
+ describe('query engine', () => {
140
+ let db: ScipDatabase;
141
+ let tempDir: string;
142
+ let dbPath: string;
143
+
144
+ beforeAll(() => {
145
+ tempDir = mkdtempSync(join(tmpdir(), 'scip-query-test-'));
146
+ dbPath = join(tempDir, 'index.db');
147
+ createFixtureDb(dbPath);
148
+
149
+ const config: ScipQueryConfig = {
150
+ dbPath,
151
+ indexPath: join(tempDir, 'index.scip'),
152
+ projectRoot: tempDir,
153
+ };
154
+ db = new ScipDatabase(config);
155
+ });
156
+
157
+ afterAll(() => {
158
+ db.close();
159
+ });
160
+
161
+ describe('stats', () => {
162
+ it('returns correct counts', () => {
163
+ const s = queries.stats(db);
164
+ expect(s.documents).toBe(7);
165
+ expect(s.symbols).toBe(8);
166
+ expect(s.definitions).toBeGreaterThan(0);
167
+ expect(s.references).toBeGreaterThan(0);
168
+ });
169
+ });
170
+
171
+ describe('files', () => {
172
+ it('finds files matching a pattern', () => {
173
+ const results = queries.files(db, 'auth');
174
+ const paths = results.map((r) => r.relativePath);
175
+ expect(paths).toContain('src/services/auth.service.ts');
176
+ expect(paths).toContain('src/controllers/auth.controller.ts');
177
+ });
178
+
179
+ it('finds Python files', () => {
180
+ const results = queries.files(db, 'utils');
181
+ expect(results).toHaveLength(1);
182
+ expect(results[0]!.relativePath).toBe('lib/utils.py');
183
+ });
184
+
185
+ it('finds Rust files', () => {
186
+ const results = queries.files(db, 'main.rs');
187
+ expect(results).toHaveLength(1);
188
+ });
189
+ });
190
+
191
+ describe('symbols', () => {
192
+ it('lists symbols in a TypeScript file', () => {
193
+ const results = queries.symbols(db, 'auth.service.ts');
194
+ expect(results.length).toBeGreaterThan(0);
195
+
196
+ const loginSymbol = results.find((s) => s.shortName.includes('login'));
197
+ expect(loginSymbol).toBeDefined();
198
+ expect(loginSymbol!.startLine).toBe(5);
199
+ expect(loginSymbol!.endLine).toBe(20);
200
+ });
201
+
202
+ it('returns cleaned signatures', () => {
203
+ const results = queries.symbols(db, 'auth.service.ts');
204
+ const login = results.find((s) => s.shortName.includes('login'));
205
+ expect(login?.signature).toBeDefined();
206
+ expect(login?.signature).not.toContain('```');
207
+ expect(login?.signature).not.toContain('(method)');
208
+ });
209
+ });
210
+
211
+ describe('methods', () => {
212
+ it('lists methods of a class', () => {
213
+ const results = queries.methods(db, 'AuthService');
214
+ expect(results.length).toBe(2);
215
+ const names = results.map((m) => m.name);
216
+ expect(names).toContain('login');
217
+ expect(names).toContain('logout');
218
+ });
219
+ });
220
+
221
+ describe('refs', () => {
222
+ it('finds cross-file references', () => {
223
+ const results = queries.refs(db, 'login');
224
+ const files = results.map((r) => r.relativePath);
225
+ expect(files).toContain('src/controllers/auth.controller.ts');
226
+ });
227
+ });
228
+
229
+ describe('trace', () => {
230
+ it('returns definitions and references', () => {
231
+ const result = queries.trace(db, 'login');
232
+ expect(result.definitions.length).toBeGreaterThan(0);
233
+ expect(result.definitions[0]!.relativePath).toBe('src/services/auth.service.ts');
234
+ expect(result.referencedBy.length).toBeGreaterThan(0);
235
+ });
236
+ });
237
+
238
+ describe('deps', () => {
239
+ it('finds forward dependencies', () => {
240
+ const results = queries.deps(db, 'auth.service.ts');
241
+ const paths = results.map((r) => r.relativePath);
242
+ expect(paths).toContain('src/services/user.service.ts');
243
+ });
244
+ });
245
+
246
+ describe('rdeps', () => {
247
+ it('finds reverse dependencies', () => {
248
+ const results = queries.rdeps(db, 'auth.service.ts');
249
+ const paths = results.map((r) => r.relativePath);
250
+ expect(paths).toContain('src/controllers/auth.controller.ts');
251
+ });
252
+ });
253
+
254
+ describe('system', () => {
255
+ it('returns full module map', () => {
256
+ const result = queries.system(db, 'services');
257
+ expect(result.files.length).toBe(2);
258
+ expect(result.symbols.length).toBeGreaterThan(0);
259
+ expect(result.dependedOnBy.length).toBeGreaterThan(0);
260
+ });
261
+ });
262
+
263
+ describe('surface', () => {
264
+ it('finds externally consumed symbols', () => {
265
+ const results = queries.surface(db, 'auth.service');
266
+ expect(results.length).toBeGreaterThan(0);
267
+ const consumers = results.map((r) => r.consumer);
268
+ expect(consumers).toContain('src/controllers/auth.controller.ts');
269
+ });
270
+ });
271
+
272
+ describe('dead', () => {
273
+ it('finds dead exports', () => {
274
+ const result = queries.dead(db, { minLoc: 1 });
275
+ const deadNames = result.symbols.map((s) => s.shortName);
276
+ const hasDead = deadNames.some((n) => n.includes('deadExport'));
277
+ expect(hasDead).toBe(true);
278
+ });
279
+
280
+ it('respects scope filter', () => {
281
+ const result = queries.dead(db, { scope: 'lib/', minLoc: 1 });
282
+ for (const s of result.symbols) {
283
+ expect(s.relativePath).toMatch(/^lib\//);
284
+ }
285
+ });
286
+
287
+ it('classifies dead-code vs dead-export correctly', () => {
288
+ const result = queries.dead(db, { minLoc: 1 });
289
+ const deadExportSymbol = result.symbols.find((s) => s.shortName.includes('deadExport'));
290
+ expect(deadExportSymbol?.kind).toBe('dead-code');
291
+ });
292
+
293
+ it('excludes test files by default', () => {
294
+ const result = queries.dead(db, { minLoc: 1 });
295
+ for (const s of result.symbols) {
296
+ expect(s.relativePath).not.toContain('__tests__');
297
+ }
298
+ });
299
+ });
300
+ });