@optave/codegraph 2.5.1 → 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.
- package/README.md +119 -49
- package/package.json +8 -7
- package/src/audit.js +423 -0
- package/src/batch.js +90 -0
- package/src/boundaries.js +346 -0
- package/src/builder.js +66 -2
- package/src/check.js +432 -0
- package/src/cli.js +361 -6
- package/src/cochange.js +5 -2
- package/src/communities.js +7 -1
- package/src/complexity.js +116 -9
- package/src/config.js +10 -0
- package/src/embedder.js +350 -38
- package/src/flow.js +4 -4
- package/src/index.js +28 -1
- package/src/manifesto.js +69 -1
- package/src/mcp.js +347 -19
- package/src/owners.js +359 -0
- package/src/paginate.js +35 -0
- package/src/queries.js +233 -19
- package/src/snapshot.js +149 -0
- package/src/structure.js +5 -2
- package/src/triage.js +273 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
2000
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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(`\
|
|
755
|
-
|
|
997
|
+
console.log(`\nKeyword search: "${singleQuery}" (BM25)\n`);
|
|
756
998
|
if (data.results.length === 0) {
|
|
757
|
-
console.log(' No results
|
|
999
|
+
console.log(' No results found.');
|
|
758
1000
|
} else {
|
|
759
1001
|
for (const r of data.results) {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
const data = await multiSearchData(queries, customDbPath, opts);
|
|
771
|
-
if (!data) return;
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
772
1010
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
console.log();
|
|
1018
|
+
if (opts.json) {
|
|
1019
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
783
1022
|
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
}
|
package/src/flow.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { openReadonlyOrFail } from './db.js';
|
|
9
|
-
import { paginateResult } from './paginate.js';
|
|
9
|
+
import { paginateResult, printNdjson } from './paginate.js';
|
|
10
10
|
import { isTestFile, kindIcon } from './queries.js';
|
|
11
11
|
import { FRAMEWORK_ENTRY_PREFIXES } from './structure.js';
|
|
12
12
|
|
|
@@ -204,7 +204,7 @@ export function flowData(name, dbPath, opts = {}) {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
db.close();
|
|
207
|
-
|
|
207
|
+
const base = {
|
|
208
208
|
entry,
|
|
209
209
|
depth: maxDepth,
|
|
210
210
|
steps,
|
|
@@ -213,6 +213,7 @@ export function flowData(name, dbPath, opts = {}) {
|
|
|
213
213
|
totalReached: visited.size - 1, // exclude the entry node itself
|
|
214
214
|
truncated,
|
|
215
215
|
};
|
|
216
|
+
return paginateResult(base, 'steps', { limit: opts.limit, offset: opts.offset });
|
|
216
217
|
}
|
|
217
218
|
|
|
218
219
|
/**
|
|
@@ -293,8 +294,7 @@ export function flow(name, dbPath, opts = {}) {
|
|
|
293
294
|
offset: opts.offset,
|
|
294
295
|
});
|
|
295
296
|
if (opts.ndjson) {
|
|
296
|
-
|
|
297
|
-
for (const e of data.entries) console.log(JSON.stringify(e));
|
|
297
|
+
printNdjson(data, 'entries');
|
|
298
298
|
return;
|
|
299
299
|
}
|
|
300
300
|
if (opts.json) {
|