@aprimediet/codewalker 1.0.0 → 1.2.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 +44 -50
- package/index.ts +6 -42
- package/package.json +20 -39
- package/prompts/codewalker.md +7 -0
- package/skills/codewalker/SKILL.md +43 -0
- package/src/cards.test.ts +88 -0
- package/src/cards.ts +87 -0
- package/src/db.test.ts +343 -0
- package/src/db.ts +363 -0
- package/src/extract/ctags-parse.test.ts +108 -0
- package/src/extract/ctags-parse.ts +112 -0
- package/src/extract/ctags.ts +51 -0
- package/src/extract/docs.test.ts +81 -0
- package/src/extract/docs.ts +169 -0
- package/src/extract/regex.test.ts +202 -0
- package/src/extract/regex.ts +192 -0
- package/src/format.test.ts +123 -0
- package/src/format.ts +69 -0
- package/src/git.test.ts +75 -0
- package/src/git.ts +62 -0
- package/src/index.contract.test.ts +145 -0
- package/src/index.ts +173 -0
- package/src/indexer.test.ts +138 -0
- package/src/indexer.ts +352 -0
- package/src/libs/cards.test.ts +86 -0
- package/src/libs/cards.ts +53 -0
- package/src/libs/dts.test.ts +269 -0
- package/src/libs/dts.ts +213 -0
- package/src/libs/indexer.test.ts +236 -0
- package/src/libs/indexer.ts +291 -0
- package/src/libs/resolve.test.ts +218 -0
- package/src/libs/resolve.ts +120 -0
- package/src/project.test.ts +115 -0
- package/src/project.ts +206 -0
- package/src/query.test.ts +169 -0
- package/src/query.ts +89 -0
- package/src/sync.test.ts +116 -0
- package/src/types.ts +117 -0
- package/vitest.config.ts +28 -0
- package/LICENSE +0 -21
- package/agents.ts +0 -126
- package/compat.ts +0 -217
- package/detect.ts +0 -188
- package/docs/PRD.md +0 -78
- package/prd.ts +0 -106
- package/skills/learn-this/SKILL.md +0 -325
package/src/db.test.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import Database from 'better-sqlite3';
|
|
6
|
+
import { openDb, bootstrapDb, upsertSymbol, searchSymbols, getMeta, setMeta, deleteFileSymbols, upsertLibrary, upsertLibSymbol, deleteLibrary, searchLibSymbols } from './db.ts';
|
|
7
|
+
|
|
8
|
+
describe('db.ts', () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let dbPath: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-db-'));
|
|
14
|
+
dbPath = path.join(tmpDir, 'test.db');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('bootstrapDb', () => {
|
|
22
|
+
it('creates files, symbols, symbols_fts, and meta tables', () => {
|
|
23
|
+
const db = new Database(dbPath);
|
|
24
|
+
bootstrapDb(db);
|
|
25
|
+
db.close();
|
|
26
|
+
|
|
27
|
+
// Re-open and check tables exist
|
|
28
|
+
const db2 = new Database(dbPath);
|
|
29
|
+
const tables = db2.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
|
|
30
|
+
const tableNames = tables.map(t => t.name);
|
|
31
|
+
expect(tableNames).toContain('files');
|
|
32
|
+
expect(tableNames).toContain('symbols');
|
|
33
|
+
expect(tableNames).toContain('meta');
|
|
34
|
+
|
|
35
|
+
// Check FTS virtual table (shows as 'table' type in sqlite_master)
|
|
36
|
+
const ftsTables = db2.prepare("SELECT name FROM sqlite_master WHERE name='symbols_fts'").all() as { name: string }[];
|
|
37
|
+
expect(ftsTables.length).toBe(1);
|
|
38
|
+
expect(ftsTables[0]!.name).toBe('symbols_fts');
|
|
39
|
+
db2.close();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('is idempotent (can be called twice)', () => {
|
|
43
|
+
const db = new Database(dbPath);
|
|
44
|
+
bootstrapDb(db);
|
|
45
|
+
bootstrapDb(db);
|
|
46
|
+
db.close();
|
|
47
|
+
// No error = idempotent
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('sets user_version to 1', () => {
|
|
51
|
+
const db = new Database(dbPath);
|
|
52
|
+
bootstrapDb(db);
|
|
53
|
+
const version = db.pragma('user_version', { simple: true }) as number;
|
|
54
|
+
expect(version).toBe(2);
|
|
55
|
+
db.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('creates libraries, lib_symbols, and lib_symbols_fts tables', () => {
|
|
59
|
+
const db = openDb(dbPath);
|
|
60
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
|
|
61
|
+
const tableNames = tables.map(t => t.name);
|
|
62
|
+
expect(tableNames).toContain('libraries');
|
|
63
|
+
expect(tableNames).toContain('lib_symbols');
|
|
64
|
+
|
|
65
|
+
const ftsTables = db.prepare("SELECT name FROM sqlite_master WHERE name='lib_symbols_fts'").all() as { name: string }[];
|
|
66
|
+
expect(ftsTables.length).toBe(1);
|
|
67
|
+
db.close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not destroy existing symbols tables (additive upgrade)', () => {
|
|
71
|
+
// Bootstrap v1 DB first, then re-bootstrap (simulate upgrade)
|
|
72
|
+
const db = new Database(dbPath);
|
|
73
|
+
bootstrapDb(db);
|
|
74
|
+
upsertSymbol(db, {
|
|
75
|
+
name: 'keep', kind: 'function', file_path: 'src/a.ts',
|
|
76
|
+
line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Call bootstrapDb again (simulates upgrade to v2 adds lib tables)
|
|
80
|
+
bootstrapDb(db);
|
|
81
|
+
|
|
82
|
+
// Symbol still there
|
|
83
|
+
const symbols = searchSymbols(db, 'keep', undefined, 10);
|
|
84
|
+
expect(symbols).toHaveLength(1);
|
|
85
|
+
|
|
86
|
+
// Lib tables exist
|
|
87
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
|
|
88
|
+
expect(tables.map(t => t.name)).toContain('lib_symbols');
|
|
89
|
+
db.close();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('openDb', () => {
|
|
94
|
+
it('opens a DB and bootstraps it', () => {
|
|
95
|
+
const db = openDb(dbPath);
|
|
96
|
+
expect(db.open).toBe(true);
|
|
97
|
+
// Tables exist
|
|
98
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM sqlite_master WHERE type='table'").get() as { c: number };
|
|
99
|
+
expect(count.c).toBeGreaterThan(0);
|
|
100
|
+
db.close();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('symbols CRUD', () => {
|
|
105
|
+
it('inserts a symbol and finds it via FTS MATCH', () => {
|
|
106
|
+
const db = openDb(dbPath);
|
|
107
|
+
|
|
108
|
+
upsertSymbol(db, {
|
|
109
|
+
name: 'myFunc',
|
|
110
|
+
kind: 'function',
|
|
111
|
+
file_path: 'src/test.ts',
|
|
112
|
+
line_start: 10,
|
|
113
|
+
line_end: 20,
|
|
114
|
+
signature: '(x: number) => string',
|
|
115
|
+
doc: 'Does something useful',
|
|
116
|
+
summary: '',
|
|
117
|
+
card_path: '/cards/myFunc.md',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// FTS search
|
|
121
|
+
const results = searchSymbols(db, 'myFunc', undefined, 10);
|
|
122
|
+
expect(results).toHaveLength(1);
|
|
123
|
+
expect(results[0]!.name).toBe('myFunc');
|
|
124
|
+
expect(results[0]!.kind).toBe('function');
|
|
125
|
+
|
|
126
|
+
db.close();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('bm25 ranks name hit above doc-only hit', () => {
|
|
130
|
+
const db = openDb(dbPath);
|
|
131
|
+
|
|
132
|
+
// Doc hit: "token" only appears in doc text, not in name
|
|
133
|
+
upsertSymbol(db, {
|
|
134
|
+
name: 'loadData',
|
|
135
|
+
kind: 'function',
|
|
136
|
+
file_path: 'src/a.ts',
|
|
137
|
+
line_start: 1,
|
|
138
|
+
line_end: 5,
|
|
139
|
+
signature: '() => void',
|
|
140
|
+
doc: 'Helper to refresh the auth token',
|
|
141
|
+
summary: '',
|
|
142
|
+
card_path: '',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Name hit: "token" is in the name
|
|
146
|
+
upsertSymbol(db, {
|
|
147
|
+
name: 'refreshToken',
|
|
148
|
+
kind: 'function',
|
|
149
|
+
file_path: 'src/b.ts',
|
|
150
|
+
line_start: 10,
|
|
151
|
+
line_end: 15,
|
|
152
|
+
signature: '() => Promise<string>',
|
|
153
|
+
doc: 'Gets a new token',
|
|
154
|
+
summary: '',
|
|
155
|
+
card_path: '',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const results = searchSymbols(db, 'token', undefined, 10);
|
|
159
|
+
expect(results.length).toBeGreaterThanOrEqual(2);
|
|
160
|
+
// First result should be the name hit (name is weighted higher in bm25)
|
|
161
|
+
expect(results[0]!.name).toBe('refreshToken');
|
|
162
|
+
|
|
163
|
+
db.close();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('re-indexing a file is idempotent (no duplicate rows)', () => {
|
|
167
|
+
const db = openDb(dbPath);
|
|
168
|
+
|
|
169
|
+
// First insert
|
|
170
|
+
upsertSymbol(db, {
|
|
171
|
+
name: 'foo', kind: 'function', file_path: 'src/test.ts',
|
|
172
|
+
line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Re-insert same file (simulate reindex)
|
|
176
|
+
deleteFileSymbols(db, 'src/test.ts');
|
|
177
|
+
upsertSymbol(db, {
|
|
178
|
+
name: 'foo', kind: 'function', file_path: 'src/test.ts',
|
|
179
|
+
line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const results = searchSymbols(db, 'foo', undefined, 10);
|
|
183
|
+
expect(results).toHaveLength(1);
|
|
184
|
+
|
|
185
|
+
db.close();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('deleting a file removes only its symbols', () => {
|
|
189
|
+
const db = openDb(dbPath);
|
|
190
|
+
|
|
191
|
+
upsertSymbol(db, {
|
|
192
|
+
name: 'keep', kind: 'function', file_path: 'src/keep.ts',
|
|
193
|
+
line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
|
|
194
|
+
});
|
|
195
|
+
upsertSymbol(db, {
|
|
196
|
+
name: 'remove', kind: 'function', file_path: 'src/remove.ts',
|
|
197
|
+
line_start: 2, line_end: 2, signature: '', doc: '', summary: '', card_path: '',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
deleteFileSymbols(db, 'src/remove.ts');
|
|
201
|
+
|
|
202
|
+
const all = searchSymbols(db, '', undefined, 10);
|
|
203
|
+
expect(all).toHaveLength(1);
|
|
204
|
+
expect(all[0]!.name).toBe('keep');
|
|
205
|
+
|
|
206
|
+
db.close();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('library CRUD', () => {
|
|
211
|
+
it('upsertLibrary inserts or updates a library record', () => {
|
|
212
|
+
const db = openDb(dbPath);
|
|
213
|
+
upsertLibrary(db, { name: 'hono', version: '4.6.3', source: 'node_modules', dts_path: '/a.d.ts', readme: 'Hono web framework' });
|
|
214
|
+
|
|
215
|
+
const row = db.prepare("SELECT * FROM libraries WHERE name = ?").get('hono') as any;
|
|
216
|
+
expect(row).not.toBeUndefined();
|
|
217
|
+
expect(row.version).toBe('4.6.3');
|
|
218
|
+
expect(row.source).toBe('node_modules');
|
|
219
|
+
db.close();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('upsertLibSymbol inserts a lib symbol and it is FTS-searchable', () => {
|
|
223
|
+
const db = openDb(dbPath);
|
|
224
|
+
upsertLibSymbol(db, {
|
|
225
|
+
lib: 'hono', version: '4.6.3', name: 'createMiddleware',
|
|
226
|
+
kind: 'function', signature: 'export declare function createMiddleware(...)',
|
|
227
|
+
doc: 'Define a typed middleware handler.', summary: 'Define a typed middleware handler.',
|
|
228
|
+
card_path: '/cards/hono/createMiddleware.md',
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const results = searchLibSymbols(db, 'createMiddleware', undefined, 10);
|
|
232
|
+
expect(results).toHaveLength(1);
|
|
233
|
+
expect(results[0]!.name).toBe('createMiddleware');
|
|
234
|
+
expect(results[0]!.lib).toBe('hono');
|
|
235
|
+
expect(results[0]!.version).toBe('4.6.3');
|
|
236
|
+
expect(results[0]!.source).toBe('lib');
|
|
237
|
+
|
|
238
|
+
db.close();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('searchLibSymbols empty query returns all symbols ordered by name', () => {
|
|
242
|
+
const db = openDb(dbPath);
|
|
243
|
+
upsertLibSymbol(db, {
|
|
244
|
+
lib: 'hono', version: '4.6.3', name: 'zMiddleware',
|
|
245
|
+
kind: 'function', signature: '', doc: '', summary: '', card_path: '',
|
|
246
|
+
});
|
|
247
|
+
upsertLibSymbol(db, {
|
|
248
|
+
lib: 'hono', version: '4.6.3', name: 'aRouter',
|
|
249
|
+
kind: 'function', signature: '', doc: '', summary: '', card_path: '',
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const results = searchLibSymbols(db, '', undefined, 10);
|
|
253
|
+
expect(results).toHaveLength(2);
|
|
254
|
+
expect(results[0]!.name).toBe('aRouter');
|
|
255
|
+
expect(results[1]!.name).toBe('zMiddleware');
|
|
256
|
+
db.close();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('deleteLibrary removes symbols and FTS rows for all versions of a lib', () => {
|
|
260
|
+
const db = openDb(dbPath);
|
|
261
|
+
|
|
262
|
+
upsertLibSymbol(db, {
|
|
263
|
+
lib: 'hono', version: '4.6.3', name: 'createMiddleware',
|
|
264
|
+
kind: 'function', signature: '', doc: '', summary: '', card_path: '',
|
|
265
|
+
});
|
|
266
|
+
upsertLibSymbol(db, {
|
|
267
|
+
lib: 'hono', version: '4.5.0', name: 'oldFunc',
|
|
268
|
+
kind: 'function', signature: '', doc: '', summary: '', card_path: '',
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
deleteLibrary(db, 'hono');
|
|
272
|
+
|
|
273
|
+
const results = searchLibSymbols(db, '', undefined, 10);
|
|
274
|
+
expect(results).toHaveLength(0);
|
|
275
|
+
|
|
276
|
+
const libRow = db.prepare("SELECT * FROM libraries WHERE name = ?").get('hono') as any;
|
|
277
|
+
expect(libRow).toBeUndefined();
|
|
278
|
+
|
|
279
|
+
db.close();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('re-inserting same (lib, name) does not create duplicates', () => {
|
|
283
|
+
const db = openDb(dbPath);
|
|
284
|
+
|
|
285
|
+
upsertLibSymbol(db, {
|
|
286
|
+
lib: 'hono', version: '4.6.3', name: 'createMiddleware',
|
|
287
|
+
kind: 'function', signature: 'v1', doc: '', summary: '', card_path: '',
|
|
288
|
+
});
|
|
289
|
+
upsertLibSymbol(db, {
|
|
290
|
+
lib: 'hono', version: '4.6.3', name: 'createMiddleware',
|
|
291
|
+
kind: 'function', signature: 'v2', doc: '', summary: '', card_path: '',
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const results = searchLibSymbols(db, 'createMiddleware', undefined, 10);
|
|
295
|
+
expect(results).toHaveLength(1);
|
|
296
|
+
// Latest signature
|
|
297
|
+
expect(results[0]!.signature).toBe('v2');
|
|
298
|
+
|
|
299
|
+
db.close();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('kind filter narrows lib symbol results', () => {
|
|
303
|
+
const db = openDb(dbPath);
|
|
304
|
+
|
|
305
|
+
upsertLibSymbol(db, {
|
|
306
|
+
lib: 'hono', version: '4.6.3', name: 'myFunc',
|
|
307
|
+
kind: 'function', signature: '', doc: '', summary: '', card_path: '',
|
|
308
|
+
});
|
|
309
|
+
upsertLibSymbol(db, {
|
|
310
|
+
lib: 'hono', version: '4.6.3', name: 'MyType',
|
|
311
|
+
kind: 'type', signature: '', doc: '', summary: '', card_path: '',
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const funcs = searchLibSymbols(db, '', 'function', 10);
|
|
315
|
+
expect(funcs).toHaveLength(1);
|
|
316
|
+
expect(funcs[0]!.name).toBe('myFunc');
|
|
317
|
+
|
|
318
|
+
db.close();
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe('meta', () => {
|
|
323
|
+
it('setMeta and getMeta round-trip values', () => {
|
|
324
|
+
const db = openDb(dbPath);
|
|
325
|
+
setMeta(db, 'last_indexed_commit', 'abc123');
|
|
326
|
+
setMeta(db, 'schema_version', '1');
|
|
327
|
+
|
|
328
|
+
expect(getMeta(db, 'last_indexed_commit')).toBe('abc123');
|
|
329
|
+
expect(getMeta(db, 'schema_version')).toBe('1');
|
|
330
|
+
expect(getMeta(db, 'nonexistent')).toBeNull();
|
|
331
|
+
|
|
332
|
+
db.close();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('setMeta overwrites existing values', () => {
|
|
336
|
+
const db = openDb(dbPath);
|
|
337
|
+
setMeta(db, 'key', 'first');
|
|
338
|
+
setMeta(db, 'key', 'second');
|
|
339
|
+
expect(getMeta(db, 'key')).toBe('second');
|
|
340
|
+
db.close();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite database layer for codewalker.
|
|
3
|
+
*
|
|
4
|
+
* Uses better-sqlite3 with WAL mode. The schema follows the design in the build prompt:
|
|
5
|
+
* - `files`: tracks indexed files (path, lang, sha, indexed_at)
|
|
6
|
+
* - `symbols`: one row per extracted symbol
|
|
7
|
+
* - `symbols_fts`: FTS5 virtual table for full-text search
|
|
8
|
+
* - `meta`: key/value store for index metadata
|
|
9
|
+
*
|
|
10
|
+
* FTS5 uses external-content mode pointing at `symbols`. When re-indexing a file,
|
|
11
|
+
* delete its rows + matching FTS rows, then INSERT fresh.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import Database, { type Database as DatabaseType } from "better-sqlite3";
|
|
15
|
+
import type { LibSymbol } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
export { Database };
|
|
18
|
+
export type { DatabaseType };
|
|
19
|
+
|
|
20
|
+
/** Open a DB file path, enable WAL, and bootstrap schema. */
|
|
21
|
+
export function openDb(dbPath: string): DatabaseType {
|
|
22
|
+
const db = new Database(dbPath);
|
|
23
|
+
db.pragma("journal_mode = WAL");
|
|
24
|
+
bootstrapDb(db);
|
|
25
|
+
return db;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Bootstrap DDL — idempotent (all CREATE use IF NOT EXISTS). */
|
|
29
|
+
export function bootstrapDb(db: DatabaseType): void {
|
|
30
|
+
db.exec(`
|
|
31
|
+
PRAGMA user_version = 2;
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
34
|
+
path TEXT PRIMARY KEY,
|
|
35
|
+
lang TEXT,
|
|
36
|
+
blob_sha TEXT,
|
|
37
|
+
indexed_at TEXT
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS symbols (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
name TEXT NOT NULL,
|
|
43
|
+
kind TEXT,
|
|
44
|
+
file_path TEXT,
|
|
45
|
+
line_start INTEGER,
|
|
46
|
+
line_end INTEGER,
|
|
47
|
+
signature TEXT,
|
|
48
|
+
doc TEXT,
|
|
49
|
+
summary TEXT,
|
|
50
|
+
card_path TEXT
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
|
|
54
|
+
name, signature, doc, summary,
|
|
55
|
+
content='symbols', content_rowid='id',
|
|
56
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE TABLE IF NOT EXISTS libraries (
|
|
60
|
+
name TEXT NOT NULL,
|
|
61
|
+
version TEXT NOT NULL,
|
|
62
|
+
source TEXT,
|
|
63
|
+
dts_path TEXT,
|
|
64
|
+
readme TEXT,
|
|
65
|
+
indexed_at TEXT,
|
|
66
|
+
PRIMARY KEY (name, version)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS lib_symbols (
|
|
70
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
+
lib TEXT NOT NULL,
|
|
72
|
+
version TEXT NOT NULL,
|
|
73
|
+
name TEXT NOT NULL,
|
|
74
|
+
kind TEXT,
|
|
75
|
+
signature TEXT,
|
|
76
|
+
doc TEXT,
|
|
77
|
+
summary TEXT,
|
|
78
|
+
card_path TEXT
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS lib_symbols_fts USING fts5(
|
|
82
|
+
name, signature, doc, summary,
|
|
83
|
+
content='lib_symbols', content_rowid='id',
|
|
84
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
|
|
88
|
+
|
|
89
|
+
-- Keep the external-content FTS indexes in sync via triggers (the SQLite-documented
|
|
90
|
+
-- pattern). Code must do plain INSERT/UPDATE/DELETE on the base tables and NEVER touch
|
|
91
|
+
-- the *_fts tables directly: the 'delete' command needs the ORIGINAL column values, which
|
|
92
|
+
-- only the triggers (via old.*) have. Hand-rolled FTS maintenance with empty/placeholder
|
|
93
|
+
-- values corrupts the index ("database disk image is malformed").
|
|
94
|
+
CREATE TRIGGER IF NOT EXISTS symbols_ai AFTER INSERT ON symbols BEGIN
|
|
95
|
+
INSERT INTO symbols_fts(rowid, name, signature, doc, summary)
|
|
96
|
+
VALUES (new.id, new.name, new.signature, new.doc, new.summary);
|
|
97
|
+
END;
|
|
98
|
+
CREATE TRIGGER IF NOT EXISTS symbols_ad AFTER DELETE ON symbols BEGIN
|
|
99
|
+
INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc, summary)
|
|
100
|
+
VALUES ('delete', old.id, old.name, old.signature, old.doc, old.summary);
|
|
101
|
+
END;
|
|
102
|
+
CREATE TRIGGER IF NOT EXISTS symbols_au AFTER UPDATE ON symbols BEGIN
|
|
103
|
+
INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc, summary)
|
|
104
|
+
VALUES ('delete', old.id, old.name, old.signature, old.doc, old.summary);
|
|
105
|
+
INSERT INTO symbols_fts(rowid, name, signature, doc, summary)
|
|
106
|
+
VALUES (new.id, new.name, new.signature, new.doc, new.summary);
|
|
107
|
+
END;
|
|
108
|
+
|
|
109
|
+
CREATE TRIGGER IF NOT EXISTS lib_symbols_ai AFTER INSERT ON lib_symbols BEGIN
|
|
110
|
+
INSERT INTO lib_symbols_fts(rowid, name, signature, doc, summary)
|
|
111
|
+
VALUES (new.id, new.name, new.signature, new.doc, new.summary);
|
|
112
|
+
END;
|
|
113
|
+
CREATE TRIGGER IF NOT EXISTS lib_symbols_ad AFTER DELETE ON lib_symbols BEGIN
|
|
114
|
+
INSERT INTO lib_symbols_fts(lib_symbols_fts, rowid, name, signature, doc, summary)
|
|
115
|
+
VALUES ('delete', old.id, old.name, old.signature, old.doc, old.summary);
|
|
116
|
+
END;
|
|
117
|
+
CREATE TRIGGER IF NOT EXISTS lib_symbols_au AFTER UPDATE ON lib_symbols BEGIN
|
|
118
|
+
INSERT INTO lib_symbols_fts(lib_symbols_fts, rowid, name, signature, doc, summary)
|
|
119
|
+
VALUES ('delete', old.id, old.name, old.signature, old.doc, old.summary);
|
|
120
|
+
INSERT INTO lib_symbols_fts(rowid, name, signature, doc, summary)
|
|
121
|
+
VALUES (new.id, new.name, new.signature, new.doc, new.summary);
|
|
122
|
+
END;
|
|
123
|
+
`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Upsert a file record. */
|
|
127
|
+
export function upsertFile(
|
|
128
|
+
db: DatabaseType,
|
|
129
|
+
path: string,
|
|
130
|
+
lang: string,
|
|
131
|
+
blobSha: string,
|
|
132
|
+
): void {
|
|
133
|
+
db.prepare(
|
|
134
|
+
`INSERT INTO files (path, lang, blob_sha, indexed_at)
|
|
135
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
136
|
+
ON CONFLICT(path) DO UPDATE SET lang=excluded.lang, blob_sha=excluded.blob_sha, indexed_at=excluded.indexed_at`,
|
|
137
|
+
).run(path, lang, blobSha);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Delete file tracking row. */
|
|
141
|
+
export function deleteFile(db: DatabaseType, filePath: string): void {
|
|
142
|
+
db.prepare("DELETE FROM files WHERE path = ?").run(filePath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Insert or update a symbol. Replaces on (name, file_path) conflict. */
|
|
146
|
+
export function upsertSymbol(
|
|
147
|
+
db: DatabaseType,
|
|
148
|
+
symbol: {
|
|
149
|
+
name: string;
|
|
150
|
+
kind: string;
|
|
151
|
+
file_path: string;
|
|
152
|
+
line_start: number;
|
|
153
|
+
line_end: number;
|
|
154
|
+
signature: string;
|
|
155
|
+
doc: string;
|
|
156
|
+
summary: string;
|
|
157
|
+
card_path: string;
|
|
158
|
+
},
|
|
159
|
+
): void {
|
|
160
|
+
// Plain INSERT — the symbols_ai trigger keeps symbols_fts in sync. Callers (scan/sync) always
|
|
161
|
+
// delete a file's symbols before re-inserting, so re-indexing is idempotent without an update path.
|
|
162
|
+
db.prepare(
|
|
163
|
+
`INSERT INTO symbols (name, kind, file_path, line_start, line_end, signature, doc, summary, card_path)
|
|
164
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
165
|
+
).run(
|
|
166
|
+
symbol.name, symbol.kind, symbol.file_path, symbol.line_start, symbol.line_end,
|
|
167
|
+
symbol.signature, symbol.doc, symbol.summary, symbol.card_path,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Delete all symbols for a given file. The symbols_ad trigger removes their FTS rows. */
|
|
172
|
+
export function deleteFileSymbols(db: DatabaseType, filePath: string): void {
|
|
173
|
+
db.prepare("DELETE FROM symbols WHERE file_path = ?").run(filePath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Search symbols via FTS5 MATCH, ranked by bm25. */
|
|
177
|
+
export function searchSymbols(
|
|
178
|
+
db: DatabaseType,
|
|
179
|
+
query: string,
|
|
180
|
+
kindFilter?: string,
|
|
181
|
+
limit = 10,
|
|
182
|
+
): Array<{
|
|
183
|
+
id: number;
|
|
184
|
+
name: string;
|
|
185
|
+
kind: string;
|
|
186
|
+
file_path: string;
|
|
187
|
+
line_start: number;
|
|
188
|
+
line_end: number;
|
|
189
|
+
signature: string;
|
|
190
|
+
summary: string;
|
|
191
|
+
score: number;
|
|
192
|
+
}> {
|
|
193
|
+
if (!query.trim()) {
|
|
194
|
+
// Return all symbols ordered by name
|
|
195
|
+
let sql = "SELECT s.id, s.name, s.kind, s.file_path, s.line_start, s.line_end, s.signature, s.summary, 0.0 as score FROM symbols s";
|
|
196
|
+
const params: unknown[] = [];
|
|
197
|
+
if (kindFilter) {
|
|
198
|
+
sql += " WHERE s.kind = ?";
|
|
199
|
+
params.push(kindFilter);
|
|
200
|
+
}
|
|
201
|
+
sql += " ORDER BY s.name LIMIT ?";
|
|
202
|
+
params.push(limit);
|
|
203
|
+
return db.prepare(sql).all(...params) as typeof results;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let sql = `
|
|
207
|
+
SELECT s.id, s.name, s.kind, s.file_path, s.line_start, s.line_end, s.signature, s.summary,
|
|
208
|
+
bm25(symbols_fts, 10.0, 5.0, 1.0, 8.0) as score
|
|
209
|
+
FROM symbols_fts
|
|
210
|
+
JOIN symbols s ON s.id = symbols_fts.rowid
|
|
211
|
+
WHERE symbols_fts MATCH ?
|
|
212
|
+
`;
|
|
213
|
+
const params: unknown[] = [query];
|
|
214
|
+
|
|
215
|
+
if (kindFilter) {
|
|
216
|
+
sql += " AND s.kind = ?";
|
|
217
|
+
params.push(kindFilter);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
sql += " ORDER BY score LIMIT ?";
|
|
221
|
+
params.push(limit);
|
|
222
|
+
|
|
223
|
+
return db.prepare(sql).all(...params) as typeof results;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Upsert a library record. */
|
|
227
|
+
export function upsertLibrary(
|
|
228
|
+
db: DatabaseType,
|
|
229
|
+
pkg: { name: string; version: string; source?: string; dts_path?: string | null; readme?: string | null },
|
|
230
|
+
): void {
|
|
231
|
+
db.prepare(
|
|
232
|
+
`INSERT INTO libraries (name, version, source, dts_path, readme, indexed_at)
|
|
233
|
+
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
|
234
|
+
ON CONFLICT(name, version) DO UPDATE SET
|
|
235
|
+
source=excluded.source, dts_path=excluded.dts_path, readme=excluded.readme, indexed_at=excluded.indexed_at`,
|
|
236
|
+
).run(pkg.name, pkg.version, pkg.source ?? null, pkg.dts_path ?? null, pkg.readme ?? null);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Upsert a lib symbol. Replaces on (lib, name) duplicate. */
|
|
240
|
+
export function upsertLibSymbol(
|
|
241
|
+
db: DatabaseType,
|
|
242
|
+
symbol: {
|
|
243
|
+
lib: string;
|
|
244
|
+
version: string;
|
|
245
|
+
name: string;
|
|
246
|
+
kind: string;
|
|
247
|
+
signature: string;
|
|
248
|
+
doc: string;
|
|
249
|
+
summary: string;
|
|
250
|
+
card_path: string;
|
|
251
|
+
},
|
|
252
|
+
): void {
|
|
253
|
+
// The lib_symbols_ai / _au triggers keep lib_symbols_fts in sync — never touch the FTS table here.
|
|
254
|
+
const existing = db.prepare(
|
|
255
|
+
"SELECT id FROM lib_symbols WHERE lib = ? AND name = ?",
|
|
256
|
+
).get(symbol.lib, symbol.name) as { id: number } | undefined;
|
|
257
|
+
|
|
258
|
+
if (existing) {
|
|
259
|
+
db.prepare(
|
|
260
|
+
`UPDATE lib_symbols SET version=?, kind=?, signature=?, doc=?, summary=?, card_path=?
|
|
261
|
+
WHERE id = ?`,
|
|
262
|
+
).run(
|
|
263
|
+
symbol.version, symbol.kind, symbol.signature,
|
|
264
|
+
symbol.doc, symbol.summary, symbol.card_path,
|
|
265
|
+
existing.id,
|
|
266
|
+
);
|
|
267
|
+
} else {
|
|
268
|
+
db.prepare(
|
|
269
|
+
`INSERT INTO lib_symbols (lib, version, name, kind, signature, doc, summary, card_path)
|
|
270
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
271
|
+
).run(
|
|
272
|
+
symbol.lib, symbol.version, symbol.name, symbol.kind,
|
|
273
|
+
symbol.signature, symbol.doc, symbol.summary, symbol.card_path,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Delete all lib_symbols (all versions) for a library, then the libraries rows.
|
|
279
|
+
* The lib_symbols_ad trigger removes the FTS rows. */
|
|
280
|
+
export function deleteLibrary(db: DatabaseType, libName: string): void {
|
|
281
|
+
db.prepare("DELETE FROM lib_symbols WHERE lib = ?").run(libName);
|
|
282
|
+
db.prepare("DELETE FROM libraries WHERE name = ?").run(libName);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Search lib symbols via FTS5 MATCH, ranked by bm25.
|
|
287
|
+
* Empty query returns all symbols ordered by name.
|
|
288
|
+
*/
|
|
289
|
+
export function searchLibSymbols(
|
|
290
|
+
db: DatabaseType,
|
|
291
|
+
query: string,
|
|
292
|
+
kindFilter?: string,
|
|
293
|
+
limit = 10,
|
|
294
|
+
): Array<{
|
|
295
|
+
id: number;
|
|
296
|
+
name: string;
|
|
297
|
+
kind: string;
|
|
298
|
+
lib: string;
|
|
299
|
+
version: string;
|
|
300
|
+
file_path: string;
|
|
301
|
+
line_start: number;
|
|
302
|
+
line_end: number;
|
|
303
|
+
signature: string;
|
|
304
|
+
summary: string;
|
|
305
|
+
score: number;
|
|
306
|
+
source: "lib";
|
|
307
|
+
}> {
|
|
308
|
+
if (!query.trim()) {
|
|
309
|
+
let sql = "SELECT s.id, s.name, s.kind, s.lib, s.version, s.signature, s.summary, 0.0 as score FROM lib_symbols s";
|
|
310
|
+
const params: unknown[] = [];
|
|
311
|
+
if (kindFilter) {
|
|
312
|
+
sql += " WHERE s.kind = ?";
|
|
313
|
+
params.push(kindFilter);
|
|
314
|
+
}
|
|
315
|
+
sql += " ORDER BY s.name LIMIT ?";
|
|
316
|
+
params.push(limit);
|
|
317
|
+
return db.prepare(sql).all(...params).map((r: any) => ({
|
|
318
|
+
...r,
|
|
319
|
+
file_path: "",
|
|
320
|
+
line_start: 0,
|
|
321
|
+
line_end: 0,
|
|
322
|
+
source: "lib" as const,
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let sql = `
|
|
327
|
+
SELECT s.id, s.name, s.kind, s.lib, s.version, s.signature, s.summary,
|
|
328
|
+
bm25(lib_symbols_fts, 10.0, 5.0, 1.0, 8.0) as score
|
|
329
|
+
FROM lib_symbols_fts
|
|
330
|
+
JOIN lib_symbols s ON s.id = lib_symbols_fts.rowid
|
|
331
|
+
WHERE lib_symbols_fts MATCH ?
|
|
332
|
+
`;
|
|
333
|
+
const params: unknown[] = [query];
|
|
334
|
+
|
|
335
|
+
if (kindFilter) {
|
|
336
|
+
sql += " AND s.kind = ?";
|
|
337
|
+
params.push(kindFilter);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
sql += " ORDER BY score LIMIT ?";
|
|
341
|
+
params.push(limit);
|
|
342
|
+
|
|
343
|
+
return db.prepare(sql).all(...params).map((r: any) => ({
|
|
344
|
+
...r,
|
|
345
|
+
file_path: "",
|
|
346
|
+
line_start: 0,
|
|
347
|
+
line_end: 0,
|
|
348
|
+
source: "lib" as const,
|
|
349
|
+
}));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Get a meta value. */
|
|
353
|
+
export function getMeta(db: DatabaseType, key: string): string | null {
|
|
354
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined;
|
|
355
|
+
return row?.value ?? null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Set a meta value. */
|
|
359
|
+
export function setMeta(db: DatabaseType, key: string, value: string): void {
|
|
360
|
+
db.prepare(
|
|
361
|
+
"INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
362
|
+
).run(key, value);
|
|
363
|
+
}
|