@optave/codegraph 2.5.0 → 2.6.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.
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import Graph from 'graphology';
3
3
  import louvain from 'graphology-communities-louvain';
4
4
  import { openReadonlyOrFail } from './db.js';
5
+ import { paginateResult, printNdjson } from './paginate.js';
5
6
  import { isTestFile } from './queries.js';
6
7
 
7
8
  // ─── Graph Construction ───────────────────────────────────────────────
@@ -201,7 +202,7 @@ export function communitiesData(customDbPath, opts = {}) {
201
202
 
202
203
  const driftScore = Math.round(((splitRatio + mergeRatio) / 2) * 100);
203
204
 
204
- return {
205
+ const base = {
205
206
  communities: opts.drift ? [] : communities,
206
207
  modularity: +modularity.toFixed(4),
207
208
  drift: { splitCandidates, mergeCandidates },
@@ -212,6 +213,7 @@ export function communitiesData(customDbPath, opts = {}) {
212
213
  driftScore,
213
214
  },
214
215
  };
216
+ return paginateResult(base, 'communities', { limit: opts.limit, offset: opts.offset });
215
217
  }
216
218
 
217
219
  /**
@@ -238,6 +240,10 @@ export function communitySummaryForStats(customDbPath, opts = {}) {
238
240
  export function communities(customDbPath, opts = {}) {
239
241
  const data = communitiesData(customDbPath, opts);
240
242
 
243
+ if (opts.ndjson) {
244
+ printNdjson(data, 'communities');
245
+ return;
246
+ }
241
247
  if (opts.json) {
242
248
  console.log(JSON.stringify(data, null, 2));
243
249
  return;
package/src/complexity.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { loadConfig } from './config.js';
4
4
  import { openReadonlyOrFail } from './db.js';
5
5
  import { info } from './logger.js';
6
+ import { paginateResult, printNdjson } from './paginate.js';
6
7
  import { LANGUAGE_REGISTRY } from './parser.js';
7
8
  import { isTestFile } from './queries.js';
8
9
 
@@ -1799,7 +1800,6 @@ export async function buildComplexityMetrics(db, fileSymbols, rootDir, _engineOp
1799
1800
  */
1800
1801
  export function complexityData(customDbPath, opts = {}) {
1801
1802
  const db = openReadonlyOrFail(customDbPath);
1802
- const limit = opts.limit || 20;
1803
1803
  const sort = opts.sort || 'cognitive';
1804
1804
  const noTests = opts.noTests || false;
1805
1805
  const aboveThreshold = opts.aboveThreshold || false;
@@ -1887,13 +1887,19 @@ export function complexityData(customDbPath, opts = {}) {
1887
1887
  FROM function_complexity fc
1888
1888
  JOIN nodes n ON fc.node_id = n.id
1889
1889
  ${where} ${having}
1890
- ORDER BY ${orderBy}
1891
- LIMIT ?`,
1890
+ ORDER BY ${orderBy}`,
1892
1891
  )
1893
- .all(...params, limit);
1892
+ .all(...params);
1894
1893
  } catch {
1894
+ // Check if graph has nodes even though complexity table is missing/empty
1895
+ let hasGraph = false;
1896
+ try {
1897
+ hasGraph = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c > 0;
1898
+ } catch {
1899
+ /* ignore */
1900
+ }
1895
1901
  db.close();
1896
- return { functions: [], summary: null, thresholds };
1902
+ return { functions: [], summary: null, thresholds, hasGraph };
1897
1903
  }
1898
1904
 
1899
1905
  // Post-filter test files if needed (belt-and-suspenders for isTestFile)
@@ -1979,8 +1985,99 @@ export function complexityData(customDbPath, opts = {}) {
1979
1985
  /* ignore */
1980
1986
  }
1981
1987
 
1988
+ // When summary is null (no complexity rows), check if graph has nodes
1989
+ let hasGraph = false;
1990
+ if (summary === null) {
1991
+ try {
1992
+ hasGraph = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c > 0;
1993
+ } catch {
1994
+ /* ignore */
1995
+ }
1996
+ }
1997
+
1982
1998
  db.close();
1983
- return { functions, summary, thresholds };
1999
+ const base = { functions, summary, thresholds, hasGraph };
2000
+ return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset });
2001
+ }
2002
+
2003
+ /**
2004
+ * Generator: stream complexity rows one-by-one using .iterate() for memory efficiency.
2005
+ * @param {string} [customDbPath]
2006
+ * @param {object} [opts]
2007
+ * @param {boolean} [opts.noTests]
2008
+ * @param {string} [opts.file]
2009
+ * @param {string} [opts.target]
2010
+ * @param {string} [opts.kind]
2011
+ * @param {string} [opts.sort]
2012
+ * @yields {{ name: string, kind: string, file: string, line: number, cognitive: number, cyclomatic: number, maxNesting: number, loc: number, sloc: number }}
2013
+ */
2014
+ export function* iterComplexity(customDbPath, opts = {}) {
2015
+ const db = openReadonlyOrFail(customDbPath);
2016
+ try {
2017
+ const noTests = opts.noTests || false;
2018
+ const sort = opts.sort || 'cognitive';
2019
+
2020
+ let where = "WHERE n.kind IN ('function','method')";
2021
+ const params = [];
2022
+
2023
+ if (noTests) {
2024
+ where += ` AND n.file NOT LIKE '%.test.%'
2025
+ AND n.file NOT LIKE '%.spec.%'
2026
+ AND n.file NOT LIKE '%__test__%'
2027
+ AND n.file NOT LIKE '%__tests__%'
2028
+ AND n.file NOT LIKE '%.stories.%'`;
2029
+ }
2030
+ if (opts.target) {
2031
+ where += ' AND n.name LIKE ?';
2032
+ params.push(`%${opts.target}%`);
2033
+ }
2034
+ if (opts.file) {
2035
+ where += ' AND n.file LIKE ?';
2036
+ params.push(`%${opts.file}%`);
2037
+ }
2038
+ if (opts.kind) {
2039
+ where += ' AND n.kind = ?';
2040
+ params.push(opts.kind);
2041
+ }
2042
+
2043
+ const orderMap = {
2044
+ cognitive: 'fc.cognitive DESC',
2045
+ cyclomatic: 'fc.cyclomatic DESC',
2046
+ nesting: 'fc.max_nesting DESC',
2047
+ mi: 'fc.maintainability_index ASC',
2048
+ volume: 'fc.halstead_volume DESC',
2049
+ effort: 'fc.halstead_effort DESC',
2050
+ bugs: 'fc.halstead_bugs DESC',
2051
+ loc: 'fc.loc DESC',
2052
+ };
2053
+ const orderBy = orderMap[sort] || 'fc.cognitive DESC';
2054
+
2055
+ const stmt = db.prepare(
2056
+ `SELECT n.name, n.kind, n.file, n.line, n.end_line,
2057
+ fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.loc, fc.sloc
2058
+ FROM function_complexity fc
2059
+ JOIN nodes n ON fc.node_id = n.id
2060
+ ${where}
2061
+ ORDER BY ${orderBy}`,
2062
+ );
2063
+ for (const r of stmt.iterate(...params)) {
2064
+ if (noTests && isTestFile(r.file)) continue;
2065
+ yield {
2066
+ name: r.name,
2067
+ kind: r.kind,
2068
+ file: r.file,
2069
+ line: r.line,
2070
+ endLine: r.end_line || null,
2071
+ cognitive: r.cognitive,
2072
+ cyclomatic: r.cyclomatic,
2073
+ maxNesting: r.max_nesting,
2074
+ loc: r.loc || 0,
2075
+ sloc: r.sloc || 0,
2076
+ };
2077
+ }
2078
+ } finally {
2079
+ db.close();
2080
+ }
1984
2081
  }
1985
2082
 
1986
2083
  /**
@@ -1989,6 +2086,10 @@ export function complexityData(customDbPath, opts = {}) {
1989
2086
  export function complexity(customDbPath, opts = {}) {
1990
2087
  const data = complexityData(customDbPath, opts);
1991
2088
 
2089
+ if (opts.ndjson) {
2090
+ printNdjson(data, 'functions');
2091
+ return;
2092
+ }
1992
2093
  if (opts.json) {
1993
2094
  console.log(JSON.stringify(data, null, 2));
1994
2095
  return;
@@ -1996,9 +2097,15 @@ export function complexity(customDbPath, opts = {}) {
1996
2097
 
1997
2098
  if (data.functions.length === 0) {
1998
2099
  if (data.summary === null) {
1999
- console.log(
2000
- '\nNo complexity data found. Run "codegraph build" first to analyze your codebase.\n',
2001
- );
2100
+ if (data.hasGraph) {
2101
+ console.log(
2102
+ '\nNo complexity data found, but a graph exists. Run "codegraph build --no-incremental" to populate complexity metrics.\n',
2103
+ );
2104
+ } else {
2105
+ console.log(
2106
+ '\nNo complexity data found. Run "codegraph build" first to analyze your codebase.\n',
2107
+ );
2108
+ }
2002
2109
  } else {
2003
2110
  console.log('\nNo functions match the given filters.\n');
2004
2111
  }
package/src/config.js CHANGED
@@ -14,6 +14,7 @@ export const DEFAULTS = {
14
14
  build: {
15
15
  incremental: true,
16
16
  dbPath: '.codegraph/graph.db',
17
+ driftThreshold: 0.2,
17
18
  },
18
19
  query: {
19
20
  defaultDepth: 3,
@@ -36,7 +37,16 @@ export const DEFAULTS = {
36
37
  fanIn: { warn: null, fail: null },
37
38
  fanOut: { warn: null, fail: null },
38
39
  noCycles: { warn: null, fail: null },
40
+ boundaries: { warn: null, fail: null },
39
41
  },
42
+ boundaries: null,
43
+ },
44
+ check: {
45
+ cycles: true,
46
+ blastRadius: null,
47
+ signatures: true,
48
+ boundaries: true,
49
+ depth: 3,
40
50
  },
41
51
  coChange: {
42
52
  since: '1 year ago',
package/src/embedder.js CHANGED
@@ -384,6 +384,22 @@ function initEmbeddingsSchema(db) {
384
384
  value TEXT
385
385
  );
386
386
  `);
387
+
388
+ // Add full_text column (idempotent — ignore if already exists)
389
+ try {
390
+ db.exec('ALTER TABLE embeddings ADD COLUMN full_text TEXT');
391
+ } catch {
392
+ /* column already exists */
393
+ }
394
+
395
+ // FTS5 virtual table for BM25 keyword search
396
+ db.exec(`
397
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_index USING fts5(
398
+ name,
399
+ content,
400
+ tokenize='unicode61'
401
+ );
402
+ `);
387
403
  }
388
404
 
389
405
  /**
@@ -411,6 +427,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
411
427
 
412
428
  db.exec('DELETE FROM embeddings');
413
429
  db.exec('DELETE FROM embedding_meta');
430
+ db.exec('DELETE FROM fts_index');
414
431
 
415
432
  const nodes = db
416
433
  .prepare(
@@ -445,6 +462,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
445
462
 
446
463
  const texts = [];
447
464
  const nodeIds = [];
465
+ const nodeNames = [];
448
466
  const previews = [];
449
467
  const config = getModelConfig(modelKey);
450
468
  const contextWindow = config.contextWindow;
@@ -476,6 +494,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
476
494
 
477
495
  texts.push(text);
478
496
  nodeIds.push(node.id);
497
+ nodeNames.push(node.name);
479
498
  previews.push(`${node.name} (${node.kind}) -- ${file}:${node.line}`);
480
499
  }
481
500
  }
@@ -490,16 +509,19 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
490
509
  const { vectors, dim } = await embed(texts, modelKey);
491
510
 
492
511
  const insert = db.prepare(
493
- 'INSERT OR REPLACE INTO embeddings (node_id, vector, text_preview) VALUES (?, ?, ?)',
512
+ 'INSERT OR REPLACE INTO embeddings (node_id, vector, text_preview, full_text) VALUES (?, ?, ?, ?)',
494
513
  );
514
+ const insertFts = db.prepare('INSERT INTO fts_index(rowid, name, content) VALUES (?, ?, ?)');
495
515
  const insertMeta = db.prepare('INSERT OR REPLACE INTO embedding_meta (key, value) VALUES (?, ?)');
496
516
  const insertAll = db.transaction(() => {
497
517
  for (let i = 0; i < vectors.length; i++) {
498
- insert.run(nodeIds[i], Buffer.from(vectors[i].buffer), previews[i]);
518
+ insert.run(nodeIds[i], Buffer.from(vectors[i].buffer), previews[i], texts[i]);
519
+ insertFts.run(nodeIds[i], nodeNames[i], texts[i]);
499
520
  }
500
521
  insertMeta.run('model', config.name);
501
522
  insertMeta.run('dim', String(dim));
502
523
  insertMeta.run('count', String(vectors.length));
524
+ insertMeta.run('fts_count', String(vectors.length));
503
525
  insertMeta.run('strategy', strategy);
504
526
  insertMeta.run('built_at', new Date().toISOString());
505
527
  if (overflowCount > 0) {
@@ -731,71 +753,361 @@ export async function multiSearchData(queries, customDbPath, opts = {}) {
731
753
  }
732
754
 
733
755
  /**
734
- * Semantic search with pre-filter support CLI wrapper with multi-query detection.
756
+ * Sanitize a user query for FTS5 MATCH syntax.
757
+ * Wraps each token as an implicit OR and escapes special FTS5 characters.
758
+ */
759
+ function sanitizeFtsQuery(query) {
760
+ // Remove FTS5 special chars that could cause syntax errors
761
+ const cleaned = query.replace(/[*"():^{}~<>]/g, ' ').trim();
762
+ if (!cleaned) return null;
763
+ // Split into tokens, wrap with OR for multi-token queries
764
+ const tokens = cleaned.split(/\s+/).filter((t) => t.length > 0);
765
+ if (tokens.length === 0) return null;
766
+ if (tokens.length === 1) return `"${tokens[0]}"`;
767
+ return tokens.map((t) => `"${t}"`).join(' OR ');
768
+ }
769
+
770
+ /**
771
+ * Check if the FTS5 index exists in the database.
772
+ * Returns true if fts_index table exists and has rows, false otherwise.
773
+ */
774
+ function hasFtsIndex(db) {
775
+ try {
776
+ const row = db.prepare('SELECT COUNT(*) as c FROM fts_index').get();
777
+ return row.c > 0;
778
+ } catch {
779
+ return false;
780
+ }
781
+ }
782
+
783
+ /**
784
+ * BM25 keyword search via FTS5.
785
+ * Returns { results: [{ name, kind, file, line, bm25Score }] } or null if no FTS5 index.
786
+ */
787
+ export function ftsSearchData(query, customDbPath, opts = {}) {
788
+ const limit = opts.limit || 15;
789
+ const noTests = opts.noTests || false;
790
+ const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./;
791
+
792
+ const db = openReadonlyOrFail(customDbPath);
793
+
794
+ if (!hasFtsIndex(db)) {
795
+ db.close();
796
+ return null;
797
+ }
798
+
799
+ const ftsQuery = sanitizeFtsQuery(query);
800
+ if (!ftsQuery) {
801
+ db.close();
802
+ return { results: [] };
803
+ }
804
+
805
+ let sql = `
806
+ SELECT f.rowid AS node_id, rank AS bm25_score,
807
+ n.name, n.kind, n.file, n.line
808
+ FROM fts_index f
809
+ JOIN nodes n ON f.rowid = n.id
810
+ WHERE fts_index MATCH ?
811
+ `;
812
+ const params = [ftsQuery];
813
+
814
+ if (opts.kind) {
815
+ sql += ' AND n.kind = ?';
816
+ params.push(opts.kind);
817
+ }
818
+
819
+ const isGlob = opts.filePattern && /[*?[\]]/.test(opts.filePattern);
820
+ if (opts.filePattern && !isGlob) {
821
+ sql += ' AND n.file LIKE ?';
822
+ params.push(`%${opts.filePattern}%`);
823
+ }
824
+
825
+ sql += ' ORDER BY rank LIMIT ?';
826
+ params.push(limit * 5); // fetch generous set for post-filtering
827
+
828
+ let rows;
829
+ try {
830
+ rows = db.prepare(sql).all(...params);
831
+ } catch {
832
+ // Invalid FTS5 query syntax — return empty
833
+ db.close();
834
+ return { results: [] };
835
+ }
836
+
837
+ if (isGlob) {
838
+ rows = rows.filter((row) => globMatch(row.file, opts.filePattern));
839
+ }
840
+ if (noTests) {
841
+ rows = rows.filter((row) => !TEST_PATTERN.test(row.file));
842
+ }
843
+
844
+ db.close();
845
+
846
+ const results = rows.slice(0, limit).map((row) => ({
847
+ name: row.name,
848
+ kind: row.kind,
849
+ file: row.file,
850
+ line: row.line,
851
+ bm25Score: -row.bm25_score, // FTS5 rank is negative; negate for display
852
+ }));
853
+
854
+ return { results };
855
+ }
856
+
857
+ /**
858
+ * Hybrid BM25 + semantic search with RRF fusion.
859
+ * Returns { results: [{ name, kind, file, line, rrf, bm25Score, bm25Rank, similarity, semanticRank }] }
860
+ * or null if no FTS5 index (caller should fall back to semantic-only).
861
+ */
862
+ export async function hybridSearchData(query, customDbPath, opts = {}) {
863
+ const limit = opts.limit || 15;
864
+ const k = opts.rrfK || 60;
865
+ const topK = (opts.limit || 15) * 5;
866
+
867
+ // Split semicolons for multi-query support
868
+ const queries =
869
+ typeof query === 'string'
870
+ ? query
871
+ .split(';')
872
+ .map((q) => q.trim())
873
+ .filter((q) => q.length > 0)
874
+ : [query];
875
+
876
+ // Check FTS5 availability first (sync, cheap)
877
+ const checkDb = openReadonlyOrFail(customDbPath);
878
+ const ftsAvailable = hasFtsIndex(checkDb);
879
+ checkDb.close();
880
+ if (!ftsAvailable) return null;
881
+
882
+ // Collect ranked lists: for each query, one BM25 list + one semantic list
883
+ const rankedLists = [];
884
+
885
+ for (const q of queries) {
886
+ // BM25 ranked list (sync)
887
+ const bm25Data = ftsSearchData(q, customDbPath, { ...opts, limit: topK });
888
+ if (bm25Data?.results) {
889
+ rankedLists.push(
890
+ bm25Data.results.map((r, idx) => ({
891
+ key: `${r.name}:${r.file}:${r.line}`,
892
+ rank: idx + 1,
893
+ source: 'bm25',
894
+ ...r,
895
+ })),
896
+ );
897
+ }
898
+
899
+ // Semantic ranked list (async)
900
+ const semData = await searchData(q, customDbPath, {
901
+ ...opts,
902
+ limit: topK,
903
+ minScore: opts.minScore || 0.2,
904
+ });
905
+ if (semData?.results) {
906
+ rankedLists.push(
907
+ semData.results.map((r, idx) => ({
908
+ key: `${r.name}:${r.file}:${r.line}`,
909
+ rank: idx + 1,
910
+ source: 'semantic',
911
+ ...r,
912
+ })),
913
+ );
914
+ }
915
+ }
916
+
917
+ // RRF fusion across all ranked lists
918
+ const fusionMap = new Map();
919
+ for (const list of rankedLists) {
920
+ for (const item of list) {
921
+ if (!fusionMap.has(item.key)) {
922
+ fusionMap.set(item.key, {
923
+ name: item.name,
924
+ kind: item.kind,
925
+ file: item.file,
926
+ line: item.line,
927
+ rrfScore: 0,
928
+ bm25Score: null,
929
+ bm25Rank: null,
930
+ similarity: null,
931
+ semanticRank: null,
932
+ });
933
+ }
934
+ const entry = fusionMap.get(item.key);
935
+ entry.rrfScore += 1 / (k + item.rank);
936
+ if (item.source === 'bm25') {
937
+ if (entry.bm25Rank === null || item.rank < entry.bm25Rank) {
938
+ entry.bm25Score = item.bm25Score;
939
+ entry.bm25Rank = item.rank;
940
+ }
941
+ } else {
942
+ if (entry.semanticRank === null || item.rank < entry.semanticRank) {
943
+ entry.similarity = item.similarity;
944
+ entry.semanticRank = item.rank;
945
+ }
946
+ }
947
+ }
948
+ }
949
+
950
+ const results = [...fusionMap.values()]
951
+ .sort((a, b) => b.rrfScore - a.rrfScore)
952
+ .slice(0, limit)
953
+ .map((e) => ({
954
+ name: e.name,
955
+ kind: e.kind,
956
+ file: e.file,
957
+ line: e.line,
958
+ rrf: e.rrfScore,
959
+ bm25Score: e.bm25Score,
960
+ bm25Rank: e.bm25Rank,
961
+ similarity: e.similarity,
962
+ semanticRank: e.semanticRank,
963
+ }));
964
+
965
+ return { results };
966
+ }
967
+
968
+ /**
969
+ * Search with mode support — CLI wrapper with multi-query detection.
970
+ * Modes: 'hybrid' (default), 'semantic', 'keyword'
735
971
  */
736
972
  export async function search(query, customDbPath, opts = {}) {
973
+ const mode = opts.mode || 'hybrid';
974
+
737
975
  // Split by semicolons, trim, filter empties
738
976
  const queries = query
739
977
  .split(';')
740
978
  .map((q) => q.trim())
741
979
  .filter((q) => q.length > 0);
742
980
 
743
- if (queries.length <= 1) {
744
- // Single-query path — preserve original output format
745
- const singleQuery = queries[0] || query;
746
- const data = await searchData(singleQuery, customDbPath, opts);
747
- if (!data) return;
981
+ const kindIcon = (kind) => (kind === 'function' ? 'f' : kind === 'class' ? '*' : 'o');
982
+
983
+ // ─── Keyword-only mode ──────────────────────────────────────────────
984
+ if (mode === 'keyword') {
985
+ const singleQuery = queries.length === 1 ? queries[0] : query;
986
+ const data = ftsSearchData(singleQuery, customDbPath, opts);
987
+ if (!data) {
988
+ console.log('No FTS5 index found. Run `codegraph embed` to build the keyword index.');
989
+ return;
990
+ }
748
991
 
749
992
  if (opts.json) {
750
993
  console.log(JSON.stringify(data, null, 2));
751
994
  return;
752
995
  }
753
996
 
754
- console.log(`\nSemantic search: "${singleQuery}"\n`);
755
-
997
+ console.log(`\nKeyword search: "${singleQuery}" (BM25)\n`);
756
998
  if (data.results.length === 0) {
757
- console.log(' No results above threshold.');
999
+ console.log(' No results found.');
758
1000
  } else {
759
1001
  for (const r of data.results) {
760
- const bar = '#'.repeat(Math.round(r.similarity * 20));
761
- const kindIcon = r.kind === 'function' ? 'f' : r.kind === 'class' ? '*' : 'o';
762
- console.log(` ${(r.similarity * 100).toFixed(1)}% ${bar}`);
763
- console.log(` ${kindIcon} ${r.name} -- ${r.file}:${r.line}`);
1002
+ console.log(
1003
+ ` BM25 ${r.bm25Score.toFixed(2)} ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}`,
1004
+ );
764
1005
  }
765
1006
  }
766
-
767
1007
  console.log(`\n ${data.results.length} results shown\n`);
768
- } else {
769
- // Multi-query path — RRF ranking
770
- const data = await multiSearchData(queries, customDbPath, opts);
771
- if (!data) return;
1008
+ return;
1009
+ }
772
1010
 
773
- if (opts.json) {
774
- console.log(JSON.stringify(data, null, 2));
775
- return;
776
- }
1011
+ // ─── Semantic-only mode ─────────────────────────────────────────────
1012
+ if (mode === 'semantic') {
1013
+ if (queries.length <= 1) {
1014
+ const singleQuery = queries[0] || query;
1015
+ const data = await searchData(singleQuery, customDbPath, opts);
1016
+ if (!data) return;
777
1017
 
778
- console.log(`\nMulti-query semantic search (RRF, k=${opts.rrfK || 60}):`);
779
- queries.forEach((q, i) => {
780
- console.log(` [${i + 1}] "${q}"`);
781
- });
782
- console.log();
1018
+ if (opts.json) {
1019
+ console.log(JSON.stringify(data, null, 2));
1020
+ return;
1021
+ }
783
1022
 
784
- if (data.results.length === 0) {
785
- console.log(' No results above threshold.');
1023
+ console.log(`\nSemantic search: "${singleQuery}"\n`);
1024
+ if (data.results.length === 0) {
1025
+ console.log(' No results above threshold.');
1026
+ } else {
1027
+ for (const r of data.results) {
1028
+ const bar = '#'.repeat(Math.round(r.similarity * 20));
1029
+ console.log(` ${(r.similarity * 100).toFixed(1)}% ${bar}`);
1030
+ console.log(` ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}`);
1031
+ }
1032
+ }
1033
+ console.log(`\n ${data.results.length} results shown\n`);
786
1034
  } else {
787
- for (const r of data.results) {
788
- const kindIcon = r.kind === 'function' ? 'f' : r.kind === 'class' ? '*' : 'o';
789
- console.log(` RRF ${r.rrf.toFixed(4)} ${kindIcon} ${r.name} -- ${r.file}:${r.line}`);
790
- for (const qs of r.queryScores) {
791
- const bar = '#'.repeat(Math.round(qs.similarity * 20));
1035
+ const data = await multiSearchData(queries, customDbPath, opts);
1036
+ if (!data) return;
1037
+
1038
+ if (opts.json) {
1039
+ console.log(JSON.stringify(data, null, 2));
1040
+ return;
1041
+ }
1042
+
1043
+ console.log(`\nMulti-query semantic search (RRF, k=${opts.rrfK || 60}):`);
1044
+ for (let i = 0; i < queries.length; i++) console.log(` [${i + 1}] "${queries[i]}"`);
1045
+ console.log();
1046
+ if (data.results.length === 0) {
1047
+ console.log(' No results above threshold.');
1048
+ } else {
1049
+ for (const r of data.results) {
792
1050
  console.log(
793
- ` [${queries.indexOf(qs.query) + 1}] ${(qs.similarity * 100).toFixed(1)}% ${bar} (rank ${qs.rank})`,
1051
+ ` RRF ${r.rrf.toFixed(4)} ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}`,
794
1052
  );
1053
+ for (const qs of r.queryScores) {
1054
+ const bar = '#'.repeat(Math.round(qs.similarity * 20));
1055
+ console.log(
1056
+ ` [${queries.indexOf(qs.query) + 1}] ${(qs.similarity * 100).toFixed(1)}% ${bar} (rank ${qs.rank})`,
1057
+ );
1058
+ }
795
1059
  }
796
1060
  }
1061
+ console.log(`\n ${data.results.length} results shown\n`);
797
1062
  }
1063
+ return;
1064
+ }
798
1065
 
799
- console.log(`\n ${data.results.length} results shown\n`);
1066
+ // ─── Hybrid mode (default) ──────────────────────────────────────────
1067
+ const data = await hybridSearchData(query, customDbPath, opts);
1068
+
1069
+ if (!data) {
1070
+ // No FTS5 index — fall back to semantic-only
1071
+ warn(
1072
+ 'FTS5 index not found — using semantic search only. Re-run `codegraph embed` to enable hybrid mode.',
1073
+ );
1074
+ return search(query, customDbPath, { ...opts, mode: 'semantic' });
800
1075
  }
1076
+
1077
+ if (opts.json) {
1078
+ console.log(JSON.stringify(data, null, 2));
1079
+ return;
1080
+ }
1081
+
1082
+ const rrfK = opts.rrfK || 60;
1083
+ if (queries.length <= 1) {
1084
+ const singleQuery = queries[0] || query;
1085
+ console.log(`\nHybrid search: "${singleQuery}" (BM25 + semantic, RRF k=${rrfK})\n`);
1086
+ } else {
1087
+ console.log(`\nHybrid multi-query search (BM25 + semantic, RRF k=${rrfK}):`);
1088
+ for (let i = 0; i < queries.length; i++) console.log(` [${i + 1}] "${queries[i]}"`);
1089
+ console.log();
1090
+ }
1091
+
1092
+ if (data.results.length === 0) {
1093
+ console.log(' No results found.');
1094
+ } else {
1095
+ for (const r of data.results) {
1096
+ console.log(
1097
+ ` RRF ${r.rrf.toFixed(4)} ${kindIcon(r.kind)} ${r.name} -- ${r.file}:${r.line}`,
1098
+ );
1099
+ const parts = [];
1100
+ if (r.bm25Rank != null) {
1101
+ parts.push(`BM25: rank ${r.bm25Rank} (score ${r.bm25Score.toFixed(2)})`);
1102
+ }
1103
+ if (r.semanticRank != null) {
1104
+ parts.push(`Semantic: rank ${r.semanticRank} (${(r.similarity * 100).toFixed(1)}%)`);
1105
+ }
1106
+ if (parts.length > 0) {
1107
+ console.log(` ${parts.join(' | ')}`);
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ console.log(`\n ${data.results.length} results shown\n`);
801
1113
  }