@aprimediet/codewalker 1.2.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 +264 -8
- package/src/db.ts +208 -2
- package/src/enrich.test.ts +102 -0
- package/src/enrich.ts +107 -0
- package/src/format.test.ts +51 -0
- package/src/format.ts +5 -0
- package/src/index.contract.test.ts +31 -0
- package/src/index.ts +214 -9
- package/src/indexer.heal.test.ts +90 -0
- package/src/indexer.ts +9 -1
- 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 +5 -1
- package/src/query.test.ts +77 -1
- package/src/query.ts +10 -6
- package/src/types.ts +18 -2
package/src/db.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import Database from 'better-sqlite3';
|
|
6
|
-
import { openDb, bootstrapDb, upsertSymbol, searchSymbols, getMeta, setMeta, deleteFileSymbols, upsertLibrary, upsertLibSymbol, deleteLibrary, searchLibSymbols } from './db.ts';
|
|
6
|
+
import { openDb, bootstrapDb, upsertSymbol, searchSymbols, getMeta, setMeta, deleteFileSymbols, upsertLibrary, upsertLibSymbol, deleteLibrary, searchLibSymbols, upsertNote, searchNotes, deleteNote, updateSymbolSummary, selectUnenrichedSymbols } from './db.ts';
|
|
7
7
|
|
|
8
8
|
describe('db.ts', () => {
|
|
9
9
|
let tmpDir: string;
|
|
@@ -47,11 +47,11 @@ describe('db.ts', () => {
|
|
|
47
47
|
// No error = idempotent
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
it('sets user_version to
|
|
50
|
+
it('sets user_version to 3 (v1.3 schema)', () => {
|
|
51
51
|
const db = new Database(dbPath);
|
|
52
52
|
bootstrapDb(db);
|
|
53
53
|
const version = db.pragma('user_version', { simple: true }) as number;
|
|
54
|
-
expect(version).toBe(
|
|
54
|
+
expect(version).toBe(3);
|
|
55
55
|
db.close();
|
|
56
56
|
});
|
|
57
57
|
|
|
@@ -67,8 +67,8 @@ describe('db.ts', () => {
|
|
|
67
67
|
db.close();
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it('does not destroy existing
|
|
71
|
-
// Bootstrap
|
|
70
|
+
it('does not destroy existing tables (additive upgrade, v2 → notes in v3)', () => {
|
|
71
|
+
// Bootstrap then re-bootstrap (simulate upgrade)
|
|
72
72
|
const db = new Database(dbPath);
|
|
73
73
|
bootstrapDb(db);
|
|
74
74
|
upsertSymbol(db, {
|
|
@@ -76,16 +76,19 @@ describe('db.ts', () => {
|
|
|
76
76
|
line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
// Call bootstrapDb again (simulates upgrade to
|
|
79
|
+
// Call bootstrapDb again (simulates upgrade to v3 adds notes tables)
|
|
80
80
|
bootstrapDb(db);
|
|
81
81
|
|
|
82
82
|
// Symbol still there
|
|
83
83
|
const symbols = searchSymbols(db, 'keep', undefined, 10);
|
|
84
84
|
expect(symbols).toHaveLength(1);
|
|
85
85
|
|
|
86
|
-
//
|
|
86
|
+
// Notes tables exist
|
|
87
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('
|
|
88
|
+
expect(tables.map(t => t.name)).toContain('notes');
|
|
89
|
+
|
|
90
|
+
const ftsTables = db.prepare("SELECT name FROM sqlite_master WHERE name='notes_fts'").all() as { name: string }[];
|
|
91
|
+
expect(ftsTables.length).toBe(1);
|
|
89
92
|
db.close();
|
|
90
93
|
});
|
|
91
94
|
});
|
|
@@ -319,6 +322,259 @@ describe('db.ts', () => {
|
|
|
319
322
|
});
|
|
320
323
|
});
|
|
321
324
|
|
|
325
|
+
describe('notes CRUD + FTS via triggers', () => {
|
|
326
|
+
it('creates notes + notes_fts on bootstrap', () => {
|
|
327
|
+
const db = openDb(dbPath);
|
|
328
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
|
|
329
|
+
expect(tables.map(t => t.name)).toContain('notes');
|
|
330
|
+
|
|
331
|
+
const ftsTables = db.prepare("SELECT name FROM sqlite_master WHERE name='notes_fts'").all() as { name: string }[];
|
|
332
|
+
expect(ftsTables.length).toBe(1);
|
|
333
|
+
|
|
334
|
+
// Triggers exist
|
|
335
|
+
const triggers = db.prepare("SELECT name FROM sqlite_master WHERE type='trigger'").all() as { name: string }[];
|
|
336
|
+
const triggerNames = triggers.map(t => t.name);
|
|
337
|
+
expect(triggerNames).toContain('notes_ai');
|
|
338
|
+
expect(triggerNames).toContain('notes_ad');
|
|
339
|
+
expect(triggerNames).toContain('notes_au');
|
|
340
|
+
db.close();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('upsertNote inserts a note and FTS MATCH finds it', () => {
|
|
344
|
+
const db = openDb(dbPath);
|
|
345
|
+
|
|
346
|
+
const id = upsertNote(db, {
|
|
347
|
+
note_kind: 'glossary',
|
|
348
|
+
title: 'Idempotency Key',
|
|
349
|
+
body: 'A client-supplied key that makes a retried POST safe to replay.',
|
|
350
|
+
tags: 'api,payments',
|
|
351
|
+
related: 'createCharge',
|
|
352
|
+
card_path: '/entries/glossary/idempotency-key.md',
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(typeof id).toBe('number');
|
|
356
|
+
expect(id).toBeGreaterThan(0);
|
|
357
|
+
|
|
358
|
+
// FTS search
|
|
359
|
+
const results = searchNotes(db, 'idempotency');
|
|
360
|
+
expect(results).toHaveLength(1);
|
|
361
|
+
expect(results[0]!.name).toBe('Idempotency Key');
|
|
362
|
+
expect(results[0]!.note_kind).toBe('glossary');
|
|
363
|
+
expect(results[0]!.source).toBe('note');
|
|
364
|
+
|
|
365
|
+
db.close();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('upsertNote upserts on (note_kind, title) — updating a note refreshes FTS via notes_au', () => {
|
|
369
|
+
const db = openDb(dbPath);
|
|
370
|
+
|
|
371
|
+
upsertNote(db, {
|
|
372
|
+
note_kind: 'glossary',
|
|
373
|
+
title: 'Retry Key',
|
|
374
|
+
body: 'Old description',
|
|
375
|
+
tags: '',
|
|
376
|
+
related: '',
|
|
377
|
+
card_path: '/entries/glossary/retry-key.md',
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Update the body
|
|
381
|
+
upsertNote(db, {
|
|
382
|
+
note_kind: 'glossary',
|
|
383
|
+
title: 'Retry Key',
|
|
384
|
+
body: 'New improved description with uniqueTermXYZ',
|
|
385
|
+
tags: 'updated',
|
|
386
|
+
related: '',
|
|
387
|
+
card_path: '/entries/glossary/retry-key.md',
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Should find by new text (proves notes_au fired the reindex)
|
|
391
|
+
const results = searchNotes(db, 'uniqueTermXYZ');
|
|
392
|
+
expect(results).toHaveLength(1);
|
|
393
|
+
expect(results[0]!.summary).toContain('uniqueTermXYZ');
|
|
394
|
+
|
|
395
|
+
// Should NOT have duplicates (upsert behavior)
|
|
396
|
+
const all = searchNotes(db, '');
|
|
397
|
+
expect(all).toHaveLength(1);
|
|
398
|
+
|
|
399
|
+
db.close();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('deleteNote removes a note from both base table and FTS', () => {
|
|
403
|
+
const db = openDb(dbPath);
|
|
404
|
+
|
|
405
|
+
upsertNote(db, {
|
|
406
|
+
note_kind: 'glossary',
|
|
407
|
+
title: 'Temp Term',
|
|
408
|
+
body: 'Will be deleted',
|
|
409
|
+
tags: '',
|
|
410
|
+
related: '',
|
|
411
|
+
card_path: '',
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
deleteNote(db, 'glossary', 'Temp Term');
|
|
415
|
+
|
|
416
|
+
const results = searchNotes(db, 'Temp');
|
|
417
|
+
expect(results).toHaveLength(0);
|
|
418
|
+
|
|
419
|
+
// Verify base table is also empty
|
|
420
|
+
const row = db.prepare("SELECT * FROM notes WHERE title = ?").get('Temp Term');
|
|
421
|
+
expect(row).toBeUndefined();
|
|
422
|
+
|
|
423
|
+
db.close();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('searchNotes empty query returns all notes ordered by title', () => {
|
|
427
|
+
const db = openDb(dbPath);
|
|
428
|
+
|
|
429
|
+
upsertNote(db, {
|
|
430
|
+
note_kind: 'glossary', title: 'Zebra', body: '', tags: '', related: '', card_path: '',
|
|
431
|
+
});
|
|
432
|
+
upsertNote(db, {
|
|
433
|
+
note_kind: 'decision', title: 'Alpha decision', body: '', tags: '', related: '', card_path: '',
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const results = searchNotes(db, '');
|
|
437
|
+
expect(results).toHaveLength(2);
|
|
438
|
+
expect(results[0]!.name).toBe('Alpha decision');
|
|
439
|
+
expect(results[1]!.name).toBe('Zebra');
|
|
440
|
+
|
|
441
|
+
db.close();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('searchNotes with kindFilter narrows by note_kind', () => {
|
|
445
|
+
const db = openDb(dbPath);
|
|
446
|
+
|
|
447
|
+
upsertNote(db, {
|
|
448
|
+
note_kind: 'glossary', title: 'Term', body: '', tags: '', related: '', card_path: '',
|
|
449
|
+
});
|
|
450
|
+
upsertNote(db, {
|
|
451
|
+
note_kind: 'decision', title: 'Decide', body: '', tags: '', related: '', card_path: '',
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const glossaryResults = searchNotes(db, '', 'glossary');
|
|
455
|
+
expect(glossaryResults).toHaveLength(1);
|
|
456
|
+
expect(glossaryResults[0]!.name).toBe('Term');
|
|
457
|
+
|
|
458
|
+
const decisionResults = searchNotes(db, '', 'decision');
|
|
459
|
+
expect(decisionResults).toHaveLength(1);
|
|
460
|
+
expect(decisionResults[0]!.name).toBe('Decide');
|
|
461
|
+
|
|
462
|
+
db.close();
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
describe('updateSymbolSummary', () => {
|
|
467
|
+
it('sets symbols.summary for a matching card_path and symbols_au reindexes FTS', () => {
|
|
468
|
+
const db = openDb(dbPath);
|
|
469
|
+
|
|
470
|
+
upsertSymbol(db, {
|
|
471
|
+
name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
|
|
472
|
+
line_start: 1, line_end: 10, signature: '', doc: 'Old doc', summary: '',
|
|
473
|
+
card_path: '/cards/myFunc.md',
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const result = updateSymbolSummary(db, '/cards/myFunc.md', 'A function that does X');
|
|
477
|
+
expect(result).toBe(true);
|
|
478
|
+
|
|
479
|
+
// Query FTS for the summary word — proves symbols_au trigger reindexed
|
|
480
|
+
const syms = searchSymbols(db, 'does X', undefined, 10);
|
|
481
|
+
expect(syms).toHaveLength(1);
|
|
482
|
+
expect(syms[0]!.name).toBe('myFunc');
|
|
483
|
+
expect(syms[0]!.summary).toBe('A function that does X');
|
|
484
|
+
|
|
485
|
+
db.close();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('returns false when no symbol matches card_path', () => {
|
|
489
|
+
const db = openDb(dbPath);
|
|
490
|
+
const result = updateSymbolSummary(db, '/nonexistent.md', 'summary');
|
|
491
|
+
expect(result).toBe(false);
|
|
492
|
+
db.close();
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe('selectUnenrichedSymbols', () => {
|
|
497
|
+
it('selects symbols with empty summary under a path prefix', () => {
|
|
498
|
+
const db = openDb(dbPath);
|
|
499
|
+
|
|
500
|
+
upsertSymbol(db, {
|
|
501
|
+
name: 'enriched', kind: 'function', file_path: 'src/auth/token.ts',
|
|
502
|
+
line_start: 1, line_end: 5, signature: '', doc: '', summary: 'Already done',
|
|
503
|
+
card_path: '/cards/enriched.md',
|
|
504
|
+
});
|
|
505
|
+
upsertSymbol(db, {
|
|
506
|
+
name: 'unenriched', kind: 'function', file_path: 'src/auth/token.ts',
|
|
507
|
+
line_start: 10, line_end: 20, signature: '', doc: '', summary: '',
|
|
508
|
+
card_path: '/cards/unenriched.md',
|
|
509
|
+
});
|
|
510
|
+
upsertSymbol(db, {
|
|
511
|
+
name: 'other', kind: 'function', file_path: 'src/other/util.ts',
|
|
512
|
+
line_start: 1, line_end: 1, signature: '', doc: '', summary: '',
|
|
513
|
+
card_path: '/cards/other.md',
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const results = selectUnenrichedSymbols(db, 'src/auth/', 10);
|
|
517
|
+
expect(results).toHaveLength(1);
|
|
518
|
+
expect(results[0]!.name).toBe('unenriched');
|
|
519
|
+
expect(results[0]!.card_path).toBe('/cards/unenriched.md');
|
|
520
|
+
|
|
521
|
+
db.close();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('returns empty for a prefix with no unenriched symbols', () => {
|
|
525
|
+
const db = openDb(dbPath);
|
|
526
|
+
|
|
527
|
+
upsertSymbol(db, {
|
|
528
|
+
name: 'done', kind: 'function', file_path: 'src/done.ts',
|
|
529
|
+
line_start: 1, line_end: 1, signature: '', doc: '', summary: 'Has summary',
|
|
530
|
+
card_path: '',
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
const results = selectUnenrichedSymbols(db, 'src/done.ts', 10);
|
|
534
|
+
expect(results).toHaveLength(0);
|
|
535
|
+
|
|
536
|
+
db.close();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('respects limit parameter', () => {
|
|
540
|
+
const db = openDb(dbPath);
|
|
541
|
+
|
|
542
|
+
for (let i = 0; i < 5; i++) {
|
|
543
|
+
upsertSymbol(db, {
|
|
544
|
+
name: `sym${i}`, kind: 'function', file_path: 'src/a.ts',
|
|
545
|
+
line_start: i, line_end: i, signature: '', doc: '', summary: '',
|
|
546
|
+
card_path: '',
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const results = selectUnenrichedSymbols(db, 'src/', 3);
|
|
551
|
+
expect(results).toHaveLength(3);
|
|
552
|
+
|
|
553
|
+
db.close();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('returns card_path, name, kind, file_path, line_start, line_end for each result', () => {
|
|
557
|
+
const db = openDb(dbPath);
|
|
558
|
+
|
|
559
|
+
upsertSymbol(db, {
|
|
560
|
+
name: 'myFunc', kind: 'function', file_path: 'src/test.ts',
|
|
561
|
+
line_start: 10, line_end: 20, signature: '', doc: '', summary: '',
|
|
562
|
+
card_path: '/cards/test.md',
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const results = selectUnenrichedSymbols(db, 'src/', 10);
|
|
566
|
+
expect(results).toHaveLength(1);
|
|
567
|
+
expect(results[0]!.name).toBe('myFunc');
|
|
568
|
+
expect(results[0]!.kind).toBe('function');
|
|
569
|
+
expect(results[0]!.file_path).toBe('src/test.ts');
|
|
570
|
+
expect(results[0]!.line_start).toBe(10);
|
|
571
|
+
expect(results[0]!.line_end).toBe(20);
|
|
572
|
+
expect(results[0]!.card_path).toBe('/cards/test.md');
|
|
573
|
+
|
|
574
|
+
db.close();
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
322
578
|
describe('meta', () => {
|
|
323
579
|
it('setMeta and getMeta round-trip values', () => {
|
|
324
580
|
const db = openDb(dbPath);
|
package/src/db.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import Database, { type Database as DatabaseType } from "better-sqlite3";
|
|
15
|
-
import type { LibSymbol } from "./types.ts";
|
|
15
|
+
import type { LibSymbol, NoteKind } from "./types.ts";
|
|
16
16
|
|
|
17
17
|
export { Database };
|
|
18
18
|
export type { DatabaseType };
|
|
@@ -28,7 +28,7 @@ export function openDb(dbPath: string): DatabaseType {
|
|
|
28
28
|
/** Bootstrap DDL — idempotent (all CREATE use IF NOT EXISTS). */
|
|
29
29
|
export function bootstrapDb(db: DatabaseType): void {
|
|
30
30
|
db.exec(`
|
|
31
|
-
PRAGMA user_version =
|
|
31
|
+
PRAGMA user_version = 3;
|
|
32
32
|
|
|
33
33
|
CREATE TABLE IF NOT EXISTS files (
|
|
34
34
|
path TEXT PRIMARY KEY,
|
|
@@ -120,9 +120,59 @@ export function bootstrapDb(db: DatabaseType): void {
|
|
|
120
120
|
INSERT INTO lib_symbols_fts(rowid, name, signature, doc, summary)
|
|
121
121
|
VALUES (new.id, new.name, new.signature, new.doc, new.summary);
|
|
122
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;
|
|
123
158
|
`);
|
|
124
159
|
}
|
|
125
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
|
+
|
|
126
176
|
/** Upsert a file record. */
|
|
127
177
|
export function upsertFile(
|
|
128
178
|
db: DatabaseType,
|
|
@@ -223,6 +273,162 @@ export function searchSymbols(
|
|
|
223
273
|
return db.prepare(sql).all(...params) as typeof results;
|
|
224
274
|
}
|
|
225
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
|
+
|
|
226
432
|
/** Upsert a library record. */
|
|
227
433
|
export function upsertLibrary(
|
|
228
434
|
db: DatabaseType,
|
|
@@ -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
|
+
});
|