@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/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 = 1;
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
- // Delete existing FTS row for this row first (content=sync requires manual FTS management)
97
- // We use a replace-or-insert approach: delete old, insert new
98
- const existing = db.prepare(
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
+ }