@aprimediet/codewalker 1.1.0 → 1.3.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 +28 -3
- package/package.json +1 -1
- package/prompts/codewalker.md +3 -1
- package/skills/codewalker/SKILL.md +118 -28
- package/src/cards.test.ts +123 -1
- package/src/cards.ts +53 -0
- package/src/db.test.ts +405 -3
- package/src/db.ts +402 -29
- package/src/enrich.test.ts +102 -0
- package/src/enrich.ts +107 -0
- package/src/format.test.ts +103 -0
- package/src/format.ts +11 -0
- package/src/index.contract.test.ts +77 -1
- package/src/index.ts +273 -19
- package/src/indexer.heal.test.ts +90 -0
- package/src/indexer.ts +9 -1
- 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/notes-cards.test.ts +99 -0
- package/src/notes-cards.ts +92 -0
- package/src/notes.test.ts +172 -0
- package/src/notes.ts +145 -0
- package/src/project.test.ts +12 -1
- package/src/project.ts +7 -1
- package/src/query.test.ts +148 -1
- package/src/query.ts +28 -8
- package/src/types.ts +44 -0
package/src/db.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import Database, { type Database as DatabaseType } from "better-sqlite3";
|
|
15
|
+
import type { LibSymbol, NoteKind } from "./types.ts";
|
|
15
16
|
|
|
16
17
|
export { Database };
|
|
17
18
|
export type { DatabaseType };
|
|
@@ -27,7 +28,7 @@ export function openDb(dbPath: string): DatabaseType {
|
|
|
27
28
|
/** Bootstrap DDL — idempotent (all CREATE use IF NOT EXISTS). */
|
|
28
29
|
export function bootstrapDb(db: DatabaseType): void {
|
|
29
30
|
db.exec(`
|
|
30
|
-
PRAGMA user_version =
|
|
31
|
+
PRAGMA user_version = 3;
|
|
31
32
|
|
|
32
33
|
CREATE TABLE IF NOT EXISTS files (
|
|
33
34
|
path TEXT PRIMARY KEY,
|
|
@@ -55,10 +56,123 @@ export function bootstrapDb(db: DatabaseType): void {
|
|
|
55
56
|
tokenize='unicode61 remove_diacritics 2'
|
|
56
57
|
);
|
|
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
|
+
|
|
58
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
|
+
-- v1.3: Notes table for glossary/decision bridge cards
|
|
125
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
126
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
127
|
+
note_kind TEXT NOT NULL,
|
|
128
|
+
title TEXT NOT NULL,
|
|
129
|
+
body TEXT,
|
|
130
|
+
tags TEXT,
|
|
131
|
+
related TEXT,
|
|
132
|
+
card_path TEXT,
|
|
133
|
+
created_at TEXT
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
|
137
|
+
title, body, tags,
|
|
138
|
+
content='notes', content_rowid='id',
|
|
139
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
|
|
143
|
+
INSERT INTO notes_fts(rowid, title, body, tags)
|
|
144
|
+
VALUES (new.id, new.title, new.body, new.tags);
|
|
145
|
+
END;
|
|
146
|
+
|
|
147
|
+
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
|
|
148
|
+
INSERT INTO notes_fts(notes_fts, rowid, title, body, tags)
|
|
149
|
+
VALUES ('delete', old.id, old.title, old.body, old.tags);
|
|
150
|
+
END;
|
|
151
|
+
|
|
152
|
+
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
|
|
153
|
+
INSERT INTO notes_fts(notes_fts, rowid, title, body, tags)
|
|
154
|
+
VALUES ('delete', old.id, old.title, old.body, old.tags);
|
|
155
|
+
INSERT INTO notes_fts(rowid, title, body, tags)
|
|
156
|
+
VALUES (new.id, new.title, new.body, new.tags);
|
|
157
|
+
END;
|
|
59
158
|
`);
|
|
60
159
|
}
|
|
61
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Re-derive the external-content FTS indexes from their content tables (the FTS5 'rebuild'
|
|
163
|
+
* command). This heals a stale/legacy index: a DB written by an older (pre-trigger, manual-sync)
|
|
164
|
+
* build can have a `*_fts` shadow that is silently out of sync with its base table. The
|
|
165
|
+
* `*_ad`/`*_au` triggers issue FTS5 'delete' commands using `old.*` values; if those don't match
|
|
166
|
+
* the stale index, the delete decrements counts that aren't there and corrupts the index
|
|
167
|
+
* ("database disk image is malformed"). Running 'rebuild' first makes subsequent trigger-driven
|
|
168
|
+
* deletes safe. Cheap and idempotent — it only re-tokenizes existing rows (no filesystem work).
|
|
169
|
+
*/
|
|
170
|
+
export function rebuildFtsIndexes(db: DatabaseType): void {
|
|
171
|
+
db.exec("INSERT INTO symbols_fts(symbols_fts) VALUES('rebuild')");
|
|
172
|
+
db.exec("INSERT INTO lib_symbols_fts(lib_symbols_fts) VALUES('rebuild')");
|
|
173
|
+
db.exec("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')");
|
|
174
|
+
}
|
|
175
|
+
|
|
62
176
|
/** Upsert a file record. */
|
|
63
177
|
export function upsertFile(
|
|
64
178
|
db: DatabaseType,
|
|
@@ -93,42 +207,19 @@ export function upsertSymbol(
|
|
|
93
207
|
card_path: string;
|
|
94
208
|
},
|
|
95
209
|
): void {
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
"SELECT id FROM symbols WHERE name = ? AND file_path = ?",
|
|
100
|
-
).get(symbol.name, symbol.file_path) as { id: number } | undefined;
|
|
101
|
-
|
|
102
|
-
if (existing) {
|
|
103
|
-
// FTS external content: must delete old content row
|
|
104
|
-
db.prepare("INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc, summary) VALUES ('delete', ?, '', '', '', '')").run(existing.id);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const result = db.prepare(
|
|
210
|
+
// Plain INSERT — the symbols_ai trigger keeps symbols_fts in sync. Callers (scan/sync) always
|
|
211
|
+
// delete a file's symbols before re-inserting, so re-indexing is idempotent without an update path.
|
|
212
|
+
db.prepare(
|
|
108
213
|
`INSERT INTO symbols (name, kind, file_path, line_start, line_end, signature, doc, summary, card_path)
|
|
109
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
110
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
111
|
-
kind=excluded.kind, line_start=excluded.line_start, line_end=excluded.line_end,
|
|
112
|
-
signature=excluded.signature, doc=excluded.doc, summary=excluded.summary, card_path=excluded.card_path`,
|
|
214
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
113
215
|
).run(
|
|
114
216
|
symbol.name, symbol.kind, symbol.file_path, symbol.line_start, symbol.line_end,
|
|
115
217
|
symbol.signature, symbol.doc, symbol.summary, symbol.card_path,
|
|
116
218
|
);
|
|
117
|
-
|
|
118
|
-
// Insert into FTS
|
|
119
|
-
const rowId = existing?.id ?? (result.lastInsertRowid as number);
|
|
120
|
-
db.prepare(
|
|
121
|
-
`INSERT INTO symbols_fts(rowid, name, signature, doc, summary)
|
|
122
|
-
VALUES (?, ?, ?, ?, ?)`,
|
|
123
|
-
).run(rowId, symbol.name, symbol.signature, symbol.doc, symbol.summary);
|
|
124
219
|
}
|
|
125
220
|
|
|
126
|
-
/** Delete all symbols for a given file. */
|
|
221
|
+
/** Delete all symbols for a given file. The symbols_ad trigger removes their FTS rows. */
|
|
127
222
|
export function deleteFileSymbols(db: DatabaseType, filePath: string): void {
|
|
128
|
-
const rows = db.prepare("SELECT id FROM symbols WHERE file_path = ?").all(filePath) as { id: number }[];
|
|
129
|
-
for (const row of rows) {
|
|
130
|
-
db.prepare("INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc, summary) VALUES ('delete', ?, '', '', '', '')").run(row.id);
|
|
131
|
-
}
|
|
132
223
|
db.prepare("DELETE FROM symbols WHERE file_path = ?").run(filePath);
|
|
133
224
|
}
|
|
134
225
|
|
|
@@ -182,6 +273,288 @@ export function searchSymbols(
|
|
|
182
273
|
return db.prepare(sql).all(...params) as typeof results;
|
|
183
274
|
}
|
|
184
275
|
|
|
276
|
+
// ── Notes CRUD ─────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Upsert a note keyed on (note_kind, title).
|
|
280
|
+
* Returns the row id.
|
|
281
|
+
*/
|
|
282
|
+
export function upsertNote(
|
|
283
|
+
db: DatabaseType,
|
|
284
|
+
note: { note_kind: string; title: string; body: string; tags: string; related: string; card_path: string },
|
|
285
|
+
): number {
|
|
286
|
+
const existing = db.prepare(
|
|
287
|
+
"SELECT id FROM notes WHERE note_kind = ? AND title = ?",
|
|
288
|
+
).get(note.note_kind, note.title) as { id: number } | undefined;
|
|
289
|
+
|
|
290
|
+
if (existing) {
|
|
291
|
+
db.prepare(
|
|
292
|
+
`UPDATE notes SET body=?, tags=?, related=?, card_path=?, created_at=COALESCE(created_at, datetime('now'))
|
|
293
|
+
WHERE id = ?`,
|
|
294
|
+
).run(note.body, note.tags, note.related, note.card_path, existing.id);
|
|
295
|
+
return existing.id;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const result = db.prepare(
|
|
299
|
+
`INSERT INTO notes (note_kind, title, body, tags, related, card_path, created_at)
|
|
300
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
|
|
301
|
+
).run(note.note_kind, note.title, note.body, note.tags, note.related, note.card_path);
|
|
302
|
+
return Number(result.lastInsertRowid);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Delete a note by (note_kind, title). */
|
|
306
|
+
export function deleteNote(db: DatabaseType, noteKind: string, title: string): void {
|
|
307
|
+
db.prepare("DELETE FROM notes WHERE note_kind = ? AND title = ?").run(noteKind, title);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Search notes via FTS5 MATCH, ranked by bm25.
|
|
312
|
+
* Empty query returns all notes ordered by title.
|
|
313
|
+
* Each result is shaped as a QueryResultRow with source:'note'.
|
|
314
|
+
*/
|
|
315
|
+
export function searchNotes(
|
|
316
|
+
db: DatabaseType,
|
|
317
|
+
query: string,
|
|
318
|
+
kindFilter?: NoteKind,
|
|
319
|
+
limit = 10,
|
|
320
|
+
): Array<{
|
|
321
|
+
id: number;
|
|
322
|
+
name: string;
|
|
323
|
+
kind: string;
|
|
324
|
+
summary: string;
|
|
325
|
+
score: number;
|
|
326
|
+
source: "note";
|
|
327
|
+
note_kind: NoteKind;
|
|
328
|
+
tags: string;
|
|
329
|
+
file_path: string;
|
|
330
|
+
line_start: number;
|
|
331
|
+
line_end: number;
|
|
332
|
+
}> {
|
|
333
|
+
if (!query.trim()) {
|
|
334
|
+
let sql = "SELECT n.id, n.title as name, n.note_kind as kind, n.body as summary, n.tags, 0.0 as score FROM notes n";
|
|
335
|
+
const params: unknown[] = [];
|
|
336
|
+
if (kindFilter) {
|
|
337
|
+
sql += " WHERE n.note_kind = ?";
|
|
338
|
+
params.push(kindFilter);
|
|
339
|
+
}
|
|
340
|
+
sql += " ORDER BY n.title LIMIT ?";
|
|
341
|
+
params.push(limit);
|
|
342
|
+
const rows = db.prepare(sql).all(...params) as any[];
|
|
343
|
+
return rows.map((r) => ({
|
|
344
|
+
...r,
|
|
345
|
+
source: "note" as const,
|
|
346
|
+
note_kind: r.kind as NoteKind,
|
|
347
|
+
file_path: "",
|
|
348
|
+
line_start: 0,
|
|
349
|
+
line_end: 0,
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let sql = `
|
|
354
|
+
SELECT n.id, n.title as name, n.note_kind as kind, n.body as summary, n.tags,
|
|
355
|
+
bm25(notes_fts, 10.0, 5.0, 3.0) as score
|
|
356
|
+
FROM notes_fts
|
|
357
|
+
JOIN notes n ON n.id = notes_fts.rowid
|
|
358
|
+
WHERE notes_fts MATCH ?
|
|
359
|
+
`;
|
|
360
|
+
const params: unknown[] = [query];
|
|
361
|
+
|
|
362
|
+
if (kindFilter) {
|
|
363
|
+
sql += " AND n.note_kind = ?";
|
|
364
|
+
params.push(kindFilter);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
sql += " ORDER BY score LIMIT ?";
|
|
368
|
+
params.push(limit);
|
|
369
|
+
|
|
370
|
+
const rows = db.prepare(sql).all(...params) as any[];
|
|
371
|
+
return rows.map((r) => ({
|
|
372
|
+
...r,
|
|
373
|
+
source: "note" as const,
|
|
374
|
+
note_kind: r.kind as NoteKind,
|
|
375
|
+
file_path: "",
|
|
376
|
+
line_start: 0,
|
|
377
|
+
line_end: 0,
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── Enrichment helpers ──────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Update symbols.summary for a given card_path.
|
|
385
|
+
* Returns true if a row was updated, false if no symbol matched.
|
|
386
|
+
* The existing symbols_au trigger reindexes FTS automatically.
|
|
387
|
+
*/
|
|
388
|
+
export function updateSymbolSummary(
|
|
389
|
+
db: DatabaseType,
|
|
390
|
+
cardPath: string,
|
|
391
|
+
summary: string,
|
|
392
|
+
): boolean {
|
|
393
|
+
const result = db.prepare(
|
|
394
|
+
"UPDATE symbols SET summary = ? WHERE card_path = ?",
|
|
395
|
+
).run(summary, cardPath);
|
|
396
|
+
return result.changes > 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Select unenriched symbols (summary IS NULL or empty) under a path prefix.
|
|
401
|
+
* Results ordered by file_path then line_start.
|
|
402
|
+
*/
|
|
403
|
+
export function selectUnenrichedSymbols(
|
|
404
|
+
db: DatabaseType,
|
|
405
|
+
pathPrefix: string,
|
|
406
|
+
limit: number,
|
|
407
|
+
): Array<{
|
|
408
|
+
name: string;
|
|
409
|
+
kind: string;
|
|
410
|
+
file_path: string;
|
|
411
|
+
line_start: number;
|
|
412
|
+
line_end: number;
|
|
413
|
+
card_path: string;
|
|
414
|
+
}> {
|
|
415
|
+
return db.prepare(
|
|
416
|
+
`SELECT name, kind, file_path, line_start, line_end, card_path
|
|
417
|
+
FROM symbols
|
|
418
|
+
WHERE (summary IS NULL OR summary = '')
|
|
419
|
+
AND file_path LIKE ?
|
|
420
|
+
ORDER BY file_path, line_start
|
|
421
|
+
LIMIT ?`,
|
|
422
|
+
).all(pathPrefix + "%", limit) as Array<{
|
|
423
|
+
name: string;
|
|
424
|
+
kind: string;
|
|
425
|
+
file_path: string;
|
|
426
|
+
line_start: number;
|
|
427
|
+
line_end: number;
|
|
428
|
+
card_path: string;
|
|
429
|
+
}>;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Upsert a library record. */
|
|
433
|
+
export function upsertLibrary(
|
|
434
|
+
db: DatabaseType,
|
|
435
|
+
pkg: { name: string; version: string; source?: string; dts_path?: string | null; readme?: string | null },
|
|
436
|
+
): void {
|
|
437
|
+
db.prepare(
|
|
438
|
+
`INSERT INTO libraries (name, version, source, dts_path, readme, indexed_at)
|
|
439
|
+
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
|
440
|
+
ON CONFLICT(name, version) DO UPDATE SET
|
|
441
|
+
source=excluded.source, dts_path=excluded.dts_path, readme=excluded.readme, indexed_at=excluded.indexed_at`,
|
|
442
|
+
).run(pkg.name, pkg.version, pkg.source ?? null, pkg.dts_path ?? null, pkg.readme ?? null);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Upsert a lib symbol. Replaces on (lib, name) duplicate. */
|
|
446
|
+
export function upsertLibSymbol(
|
|
447
|
+
db: DatabaseType,
|
|
448
|
+
symbol: {
|
|
449
|
+
lib: string;
|
|
450
|
+
version: string;
|
|
451
|
+
name: string;
|
|
452
|
+
kind: string;
|
|
453
|
+
signature: string;
|
|
454
|
+
doc: string;
|
|
455
|
+
summary: string;
|
|
456
|
+
card_path: string;
|
|
457
|
+
},
|
|
458
|
+
): void {
|
|
459
|
+
// The lib_symbols_ai / _au triggers keep lib_symbols_fts in sync — never touch the FTS table here.
|
|
460
|
+
const existing = db.prepare(
|
|
461
|
+
"SELECT id FROM lib_symbols WHERE lib = ? AND name = ?",
|
|
462
|
+
).get(symbol.lib, symbol.name) as { id: number } | undefined;
|
|
463
|
+
|
|
464
|
+
if (existing) {
|
|
465
|
+
db.prepare(
|
|
466
|
+
`UPDATE lib_symbols SET version=?, kind=?, signature=?, doc=?, summary=?, card_path=?
|
|
467
|
+
WHERE id = ?`,
|
|
468
|
+
).run(
|
|
469
|
+
symbol.version, symbol.kind, symbol.signature,
|
|
470
|
+
symbol.doc, symbol.summary, symbol.card_path,
|
|
471
|
+
existing.id,
|
|
472
|
+
);
|
|
473
|
+
} else {
|
|
474
|
+
db.prepare(
|
|
475
|
+
`INSERT INTO lib_symbols (lib, version, name, kind, signature, doc, summary, card_path)
|
|
476
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
477
|
+
).run(
|
|
478
|
+
symbol.lib, symbol.version, symbol.name, symbol.kind,
|
|
479
|
+
symbol.signature, symbol.doc, symbol.summary, symbol.card_path,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Delete all lib_symbols (all versions) for a library, then the libraries rows.
|
|
485
|
+
* The lib_symbols_ad trigger removes the FTS rows. */
|
|
486
|
+
export function deleteLibrary(db: DatabaseType, libName: string): void {
|
|
487
|
+
db.prepare("DELETE FROM lib_symbols WHERE lib = ?").run(libName);
|
|
488
|
+
db.prepare("DELETE FROM libraries WHERE name = ?").run(libName);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Search lib symbols via FTS5 MATCH, ranked by bm25.
|
|
493
|
+
* Empty query returns all symbols ordered by name.
|
|
494
|
+
*/
|
|
495
|
+
export function searchLibSymbols(
|
|
496
|
+
db: DatabaseType,
|
|
497
|
+
query: string,
|
|
498
|
+
kindFilter?: string,
|
|
499
|
+
limit = 10,
|
|
500
|
+
): Array<{
|
|
501
|
+
id: number;
|
|
502
|
+
name: string;
|
|
503
|
+
kind: string;
|
|
504
|
+
lib: string;
|
|
505
|
+
version: string;
|
|
506
|
+
file_path: string;
|
|
507
|
+
line_start: number;
|
|
508
|
+
line_end: number;
|
|
509
|
+
signature: string;
|
|
510
|
+
summary: string;
|
|
511
|
+
score: number;
|
|
512
|
+
source: "lib";
|
|
513
|
+
}> {
|
|
514
|
+
if (!query.trim()) {
|
|
515
|
+
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";
|
|
516
|
+
const params: unknown[] = [];
|
|
517
|
+
if (kindFilter) {
|
|
518
|
+
sql += " WHERE s.kind = ?";
|
|
519
|
+
params.push(kindFilter);
|
|
520
|
+
}
|
|
521
|
+
sql += " ORDER BY s.name LIMIT ?";
|
|
522
|
+
params.push(limit);
|
|
523
|
+
return db.prepare(sql).all(...params).map((r: any) => ({
|
|
524
|
+
...r,
|
|
525
|
+
file_path: "",
|
|
526
|
+
line_start: 0,
|
|
527
|
+
line_end: 0,
|
|
528
|
+
source: "lib" as const,
|
|
529
|
+
}));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let sql = `
|
|
533
|
+
SELECT s.id, s.name, s.kind, s.lib, s.version, s.signature, s.summary,
|
|
534
|
+
bm25(lib_symbols_fts, 10.0, 5.0, 1.0, 8.0) as score
|
|
535
|
+
FROM lib_symbols_fts
|
|
536
|
+
JOIN lib_symbols s ON s.id = lib_symbols_fts.rowid
|
|
537
|
+
WHERE lib_symbols_fts MATCH ?
|
|
538
|
+
`;
|
|
539
|
+
const params: unknown[] = [query];
|
|
540
|
+
|
|
541
|
+
if (kindFilter) {
|
|
542
|
+
sql += " AND s.kind = ?";
|
|
543
|
+
params.push(kindFilter);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
sql += " ORDER BY score LIMIT ?";
|
|
547
|
+
params.push(limit);
|
|
548
|
+
|
|
549
|
+
return db.prepare(sql).all(...params).map((r: any) => ({
|
|
550
|
+
...r,
|
|
551
|
+
file_path: "",
|
|
552
|
+
line_start: 0,
|
|
553
|
+
line_end: 0,
|
|
554
|
+
source: "lib" as const,
|
|
555
|
+
}));
|
|
556
|
+
}
|
|
557
|
+
|
|
185
558
|
/** Get a meta value. */
|
|
186
559
|
export function getMeta(db: DatabaseType, key: string): string | null {
|
|
187
560
|
const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { formatEnrichWorklist, validateEnrichPath, checkEnrichCap } from './enrich.ts';
|
|
3
|
+
import type { UnenrichedSymbol } from './enrich.ts';
|
|
4
|
+
|
|
5
|
+
function makeSym(overrides: Partial<UnenrichedSymbol> = {}): UnenrichedSymbol {
|
|
6
|
+
return {
|
|
7
|
+
name: 'myFunc',
|
|
8
|
+
kind: 'function',
|
|
9
|
+
file_path: 'src/auth/token.ts',
|
|
10
|
+
line_start: 10,
|
|
11
|
+
line_end: 30,
|
|
12
|
+
card_path: '/entries/symbols/auth-token/myFunc.md',
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('validateEnrichPath', () => {
|
|
18
|
+
it('accepts a non-empty path', () => {
|
|
19
|
+
const result = validateEnrichPath('src/auth');
|
|
20
|
+
expect(result.valid).toBe(true);
|
|
21
|
+
expect(result.error).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects empty string', () => {
|
|
25
|
+
const result = validateEnrichPath('');
|
|
26
|
+
expect(result.valid).toBe(false);
|
|
27
|
+
expect(result.error).toContain('specify a path');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects whitespace-only', () => {
|
|
31
|
+
const result = validateEnrichPath(' ');
|
|
32
|
+
expect(result.valid).toBe(false);
|
|
33
|
+
expect(result.error).toContain('specify a path');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects undefined/null', () => {
|
|
37
|
+
const result = validateEnrichPath(undefined as any);
|
|
38
|
+
expect(result.valid).toBe(false);
|
|
39
|
+
expect(result.error).toContain('specify a path');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('checkEnrichCap', () => {
|
|
44
|
+
it('returns ok when count within cap', () => {
|
|
45
|
+
const result = checkEnrichCap(5, 40);
|
|
46
|
+
expect(result.ok).toBe(true);
|
|
47
|
+
expect(result.count).toBe(5);
|
|
48
|
+
expect(result.skipped).toBe(0);
|
|
49
|
+
expect(result.error).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects when count exceeds cap', () => {
|
|
53
|
+
const result = checkEnrichCap(100, 40);
|
|
54
|
+
expect(result.ok).toBe(false);
|
|
55
|
+
expect(result.count).toBe(100);
|
|
56
|
+
expect(result.skipped).toBe(60);
|
|
57
|
+
expect(result.error).toContain('100 symbols');
|
|
58
|
+
expect(result.error).toContain('--max=100');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('uses default cap when not specified', () => {
|
|
62
|
+
const result = checkEnrichCap(50);
|
|
63
|
+
expect(result.ok).toBe(false);
|
|
64
|
+
expect(result.error).toContain('--max=50');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('accepts edge case at exactly cap', () => {
|
|
68
|
+
const result = checkEnrichCap(40, 40);
|
|
69
|
+
expect(result.ok).toBe(true);
|
|
70
|
+
expect(result.count).toBe(40);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('formatEnrichWorklist', () => {
|
|
75
|
+
it('formats symbols into compact lines', () => {
|
|
76
|
+
const syms = [
|
|
77
|
+
makeSym({ name: 'refreshToken', file_path: 'src/auth/token.ts', line_start: 42, line_end: 71 }),
|
|
78
|
+
makeSym({ name: 'validateJwt', file_path: 'src/auth/jwt.ts', line_start: 10, line_end: 30 }),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const result = formatEnrichWorklist(syms, 'src/auth');
|
|
82
|
+
expect(result).toContain('refreshToken');
|
|
83
|
+
expect(result).toContain('validateJwt');
|
|
84
|
+
expect(result).toContain('src/auth');
|
|
85
|
+
expect(result).toContain('42-71');
|
|
86
|
+
expect(result).toContain('10-30');
|
|
87
|
+
expect(result).toMatch(/function/g);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('includes instructions for the agent', () => {
|
|
91
|
+
const syms = [makeSym()];
|
|
92
|
+
const result = formatEnrichWorklist(syms, 'src/');
|
|
93
|
+
expect(result).toContain('codewalker_enrich');
|
|
94
|
+
expect(result).toContain('summary');
|
|
95
|
+
expect(result).toContain('120');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('says "No unenriched symbols" for empty array', () => {
|
|
99
|
+
const result = formatEnrichWorklist([], 'src/');
|
|
100
|
+
expect(result).toContain('No unenriched symbols');
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/enrich.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enrichment core for codewalker v1.3.
|
|
3
|
+
*
|
|
4
|
+
* Pure/lightweight helpers for the enrichment workflow:
|
|
5
|
+
* - `validateEnrichPath()` — ensures path is provided
|
|
6
|
+
* - `checkEnrichCap()` — enforces the enrichment cap guardrail
|
|
7
|
+
* - `formatEnrichWorklist()` — formats worklist items for agent display
|
|
8
|
+
*
|
|
9
|
+
* The heavy lifting (selecting unenriched symbols) lives in db.ts.
|
|
10
|
+
* The write-back path (updating card + DB) is in cards.ts + db.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Default maximum symbols per enrich command. */
|
|
14
|
+
export const DEFAULT_ENRICH_CAP = 40;
|
|
15
|
+
|
|
16
|
+
/** A single unenriched symbol from the selection query. */
|
|
17
|
+
export interface UnenrichedSymbol {
|
|
18
|
+
name: string;
|
|
19
|
+
kind: string;
|
|
20
|
+
file_path: string;
|
|
21
|
+
line_start: number;
|
|
22
|
+
line_end: number;
|
|
23
|
+
card_path: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Result of validateEnrichPath. */
|
|
27
|
+
export interface PathValidation {
|
|
28
|
+
valid: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Result of checkEnrichCap. */
|
|
33
|
+
export interface CapCheck {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
count: number;
|
|
36
|
+
/** Number of symbols over the cap (only when !ok). */
|
|
37
|
+
skipped: number;
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate that an enrichment path was provided.
|
|
43
|
+
*/
|
|
44
|
+
export function validateEnrichPath(path: string | undefined | null): PathValidation {
|
|
45
|
+
const trimmed = (path ?? "").trim();
|
|
46
|
+
if (!trimmed) {
|
|
47
|
+
return {
|
|
48
|
+
valid: false,
|
|
49
|
+
error: 'specify a path, e.g. src/auth. Bare /codewalker enrich with no path is not allowed.',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { valid: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check whether a count of unenriched symbols exceeds the cap.
|
|
57
|
+
* If ok is false, the caller should refuse and tell the user to narrow the path.
|
|
58
|
+
*
|
|
59
|
+
* @param count - Number of unenriched symbols found.
|
|
60
|
+
* @param cap - Maximum allowed (default: DEFAULT_ENRICH_CAP = 40).
|
|
61
|
+
*/
|
|
62
|
+
export function checkEnrichCap(count: number, cap: number = DEFAULT_ENRICH_CAP): CapCheck {
|
|
63
|
+
if (count > cap) {
|
|
64
|
+
const skipped = count - cap;
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
count,
|
|
68
|
+
skipped,
|
|
69
|
+
error: `Refusing to enrich ${count} symbols (cap ${cap}). ` +
|
|
70
|
+
`Narrow your path or raise the cap with --max=${count}.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, count, skipped: 0 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Format an enrichment worklist for the agent to process.
|
|
78
|
+
* Each line shows one unenriched symbol. Includes a header with instructions.
|
|
79
|
+
*/
|
|
80
|
+
export function formatEnrichWorklist(
|
|
81
|
+
symbols: UnenrichedSymbol[],
|
|
82
|
+
pathPrefix: string,
|
|
83
|
+
): string {
|
|
84
|
+
if (symbols.length === 0) {
|
|
85
|
+
return `No unenriched symbols found under "${pathPrefix}". All symbols in this path already have summaries.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const lines: string[] = [
|
|
89
|
+
`Found ${symbols.length} unenriched symbol(s) under "${pathPrefix}":`,
|
|
90
|
+
"",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
for (const sym of symbols) {
|
|
94
|
+
const loc = `${sym.file_path}:${sym.line_start}-${sym.line_end}`;
|
|
95
|
+
lines.push(` ${sym.name} · ${sym.kind} · ${loc} · card: ${sym.card_path}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
lines.push(
|
|
99
|
+
"",
|
|
100
|
+
"Instructions:",
|
|
101
|
+
` For each symbol above, read the source span and call \`codewalker_enrich\``,
|
|
102
|
+
" with a ≤120 character plain-English summary of what the symbol does.",
|
|
103
|
+
` Example: \`codewalker_enrich { card: "${symbols[0]?.card_path ?? '<card_path>'}", summary: "..." }\``,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|