@aprimediet/codewalker 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aprimediet/codewalker",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Queryable, token-economical project & code index for the pi coding agent.",
6
6
  "keywords": ["pi-package"],
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 } from './db.ts';
6
+ import { openDb, bootstrapDb, upsertSymbol, searchSymbols, getMeta, setMeta, deleteFileSymbols, upsertLibrary, upsertLibSymbol, deleteLibrary, searchLibSymbols } from './db.ts';
7
7
 
8
8
  describe('db.ts', () => {
9
9
  let tmpDir: string;
@@ -51,7 +51,41 @@ describe('db.ts', () => {
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(1);
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');
55
89
  db.close();
56
90
  });
57
91
  });
@@ -173,6 +207,118 @@ describe('db.ts', () => {
173
207
  });
174
208
  });
175
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
+
176
322
  describe('meta', () => {
177
323
  it('setMeta and getMeta round-trip values', () => {
178
324
  const db = openDb(dbPath);
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 } 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 = 2;
31
32
 
32
33
  CREATE TABLE IF NOT EXISTS files (
33
34
  path TEXT PRIMARY KEY,
@@ -55,7 +56,70 @@ 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;
59
123
  `);
60
124
  }
61
125
 
@@ -93,42 +157,19 @@ export function upsertSymbol(
93
157
  card_path: string;
94
158
  },
95
159
  ): 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(
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(
108
163
  `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`,
164
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
113
165
  ).run(
114
166
  symbol.name, symbol.kind, symbol.file_path, symbol.line_start, symbol.line_end,
115
167
  symbol.signature, symbol.doc, symbol.summary, symbol.card_path,
116
168
  );
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
169
  }
125
170
 
126
- /** Delete all symbols for a given file. */
171
+ /** Delete all symbols for a given file. The symbols_ad trigger removes their FTS rows. */
127
172
  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
173
  db.prepare("DELETE FROM symbols WHERE file_path = ?").run(filePath);
133
174
  }
134
175
 
@@ -182,6 +223,132 @@ export function searchSymbols(
182
223
  return db.prepare(sql).all(...params) as typeof results;
183
224
  }
184
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
+
185
352
  /** Get a meta value. */
186
353
  export function getMeta(db: DatabaseType, key: string): string | null {
187
354
  const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined;
@@ -2,6 +2,24 @@ import { describe, it, expect } from 'vitest';
2
2
  import { formatCompact, formatCardBody } from './format.ts';
3
3
  import type { QueryResultRow, StalenessInfo } from './types.ts';
4
4
 
5
+ function makeLibRow(overrides: Partial<QueryResultRow> = {}): QueryResultRow {
6
+ return {
7
+ id: 100,
8
+ name: 'createMiddleware',
9
+ kind: 'function',
10
+ file_path: 'hono/dist/helper.d.ts',
11
+ line_start: 0,
12
+ line_end: 0,
13
+ signature: 'export declare function createMiddleware<E>(...): MiddlewareHandler',
14
+ summary: 'Define a typed middleware handler.',
15
+ score: 0.3,
16
+ source: 'lib',
17
+ lib: 'hono',
18
+ version: '4.6.3',
19
+ ...overrides,
20
+ };
21
+ }
22
+
5
23
  function makeRow(overrides: Partial<QueryResultRow> = {}): QueryResultRow {
6
24
  return {
7
25
  id: 1,
@@ -63,6 +81,40 @@ describe('formatCompact', () => {
63
81
  });
64
82
  });
65
83
 
84
+ describe('formatCompact with lib rows', () => {
85
+ it('renders a lib row with [lib@version] origin tag', () => {
86
+ const rows = [makeLibRow()];
87
+ const result = formatCompact(rows, null);
88
+ expect(result).toContain('createMiddleware');
89
+ expect(result).toContain('function');
90
+ expect(result).toContain('[hono@4.6.3]');
91
+ expect(result).toContain('Define a typed middleware handler.');
92
+ });
93
+
94
+ it('renders mixed code and lib rows', () => {
95
+ const libRow = makeLibRow();
96
+ const codeRow: QueryResultRow = {
97
+ id: 1, name: 'myFunc', kind: 'function',
98
+ file_path: 'src/util/helper.ts', line_start: 10, line_end: 20,
99
+ signature: '(x: number) => string', summary: 'Does something', score: 0.5,
100
+ };
101
+ const result = formatCompact([codeRow, libRow], null);
102
+ expect(result).toContain('helper.ts:10-20');
103
+ expect(result).toContain('[hono@4.6.3]');
104
+ // Two lines
105
+ expect(result.split('\n')).toHaveLength(2);
106
+ });
107
+
108
+ it('bounded output for lib rows (one line per hit)', () => {
109
+ const rows = Array.from({ length: 5 }, (_, i) =>
110
+ makeLibRow({ name: `fn${i}`, lib: 'test-pkg', version: '1.0.0' })
111
+ );
112
+ const result = formatCompact(rows, null);
113
+ expect(result.split('\n')).toHaveLength(5);
114
+ expect(result).toContain('[test-pkg@1.0.0]');
115
+ });
116
+ });
117
+
66
118
  describe('formatCardBody', () => {
67
119
  it('returns the card body text', () => {
68
120
  const body = '# myFunc\n\nDoes something.\n';
package/src/format.ts CHANGED
@@ -27,6 +27,12 @@ export function formatCompact(
27
27
  }
28
28
 
29
29
  const lines = rows.map((row) => {
30
+ if (row.source === "lib" && row.lib && row.version) {
31
+ const origin = `[${row.lib}@${row.version}]`;
32
+ const summary = truncate(row.summary || "", SUMMARY_MAX);
33
+ const loc = row.file_path ? `${basename(row.file_path)}:${row.line_start}-${row.line_end}` : `lib`;
34
+ return `${row.name} · ${row.kind} · ${origin} · ${loc} · ${summary}`;
35
+ }
30
36
  const loc = `${basename(row.file_path)}:${row.line_start}-${row.line_end}`;
31
37
  const summary = truncate(row.summary || "", SUMMARY_MAX);
32
38
  return `${row.name} · ${row.kind} · ${loc} · ${summary}`;
@@ -58,10 +58,15 @@ describe('index.ts contract', () => {
58
58
  expect(queryTool).toBeDefined();
59
59
  expect(queryTool!.description).toContain('code index');
60
60
 
61
+ // Check tool has a source parameter
62
+ const toolParams = (queryTool!.parameters as any);
63
+ expect(toolParams.properties).toHaveProperty('source');
64
+
61
65
  // Check command registered
62
66
  const cmd = stub.commands.find(c => c.name === 'codewalker');
63
67
  expect(cmd).toBeDefined();
64
- expect(cmd!.description).toBeDefined();
68
+ expect(cmd!.description).toContain('libs');
69
+ expect(cmd!.description).toContain('lib');
65
70
  });
66
71
 
67
72
  it('tool.execute returns { content, details } with compact text content', async () => {
@@ -97,4 +102,44 @@ describe('index.ts contract', () => {
97
102
 
98
103
  fs.rmSync(tmpDir, { recursive: true, force: true });
99
104
  });
105
+
106
+ it('tool.execute with source="libs" returns valid result even with no lib data', async () => {
107
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-contract-libs-'));
108
+ const piDir = path.join(tmpDir, '.pi');
109
+ fs.mkdirSync(piDir, { recursive: true });
110
+ const markerId = 'test-project-libs-' + Math.random().toString(36).slice(2, 8);
111
+ fs.writeFileSync(path.join(piDir, markerId + '.md'), `---\npi-project: true\nid: ${markerId}\n---\n`);
112
+
113
+ const homePi = path.join(os.homedir(), '.pi', 'projects', markerId, 'codewalker');
114
+ fs.mkdirSync(homePi, { recursive: true });
115
+
116
+ const mod = await import('./index.ts');
117
+ const stub = createPiStub();
118
+ mod.default(stub.api as any);
119
+
120
+ const tool = stub.tools.find(t => t.name === 'codewalker_query')!;
121
+ const result = await tool.execute(
122
+ 'test-id',
123
+ { query: 'test', source: 'libs' },
124
+ new AbortController().signal,
125
+ () => {},
126
+ { cwd: tmpDir },
127
+ );
128
+
129
+ expect(result).toHaveProperty('content');
130
+ expect(result.content[0]!.text).toContain('No matches');
131
+ expect(result.details.rows).toEqual([]);
132
+
133
+ fs.rmSync(tmpDir, { recursive: true, force: true });
134
+ });
135
+
136
+ it('command description mentions libs and lib subcommands', async () => {
137
+ const mod = await import('./index.ts');
138
+ const stub = createPiStub();
139
+ mod.default(stub.api as any);
140
+
141
+ const cmd = stub.commands.find(c => c.name === 'codewalker')!;
142
+ expect(cmd.description).toContain('libs [--dev]');
143
+ expect(cmd.description).toContain('lib <pkg>');
144
+ });
100
145
  });