@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.
@@ -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,91 @@ 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
+
118
+ describe('formatCompact with note rows', () => {
119
+ it('renders a glossary note row with [glossary] prefix', () => {
120
+ const rows: QueryResultRow[] = [{
121
+ id: 1, name: 'Idempotency Key', kind: 'glossary',
122
+ file_path: '', line_start: 0, line_end: 0,
123
+ signature: '', summary: 'A client-supplied key that makes retries safe.',
124
+ score: 0.5, source: 'note', note_kind: 'glossary', tags: 'api',
125
+ }];
126
+ const result = formatCompact(rows, null);
127
+ expect(result).toContain('Idempotency Key');
128
+ expect(result).toContain('glossary');
129
+ expect(result).toContain('[glossary]');
130
+ expect(result).toContain('A client-supplied key that makes retries safe.');
131
+ });
132
+
133
+ it('renders a decision note row with [decision] prefix', () => {
134
+ const rows: QueryResultRow[] = [{
135
+ id: 1, name: 'Use SQLite', kind: 'decision',
136
+ file_path: '', line_start: 0, line_end: 0,
137
+ signature: '', summary: 'Chosen for zero infra.',
138
+ score: 0.5, source: 'note', note_kind: 'decision', tags: '',
139
+ }];
140
+ const result = formatCompact(rows, null);
141
+ expect(result).toContain('Use SQLite');
142
+ expect(result).toContain('decision');
143
+ expect(result).toContain('[decision]');
144
+ });
145
+
146
+ it('renders mixed code + lib + note rows all on separate lines', () => {
147
+ const codeRow: QueryResultRow = {
148
+ id: 1, name: 'myFunc', kind: 'function',
149
+ file_path: 'src/util/helper.ts', line_start: 10, line_end: 20,
150
+ signature: '', summary: 'Does something', score: 0.5,
151
+ };
152
+ const libRow = makeLibRow();
153
+ const noteRow: QueryResultRow = {
154
+ id: 1, name: 'Retry Key', kind: 'glossary',
155
+ file_path: '', line_start: 0, line_end: 0,
156
+ signature: '', summary: 'Key for idempotent retries.',
157
+ score: 0.5, source: 'note', note_kind: 'glossary', tags: '',
158
+ };
159
+
160
+ const result = formatCompact([codeRow, libRow, noteRow], null);
161
+ expect(result).toContain('helper.ts:10-20');
162
+ expect(result).toContain('[hono@4.6.3]');
163
+ expect(result).toContain('[glossary]');
164
+ expect(result).toContain('Retry Key');
165
+ expect(result.split('\n')).toHaveLength(3);
166
+ });
167
+ });
168
+
66
169
  describe('formatCardBody', () => {
67
170
  it('returns the card body text', () => {
68
171
  const body = '# myFunc\n\nDoes something.\n';
package/src/format.ts CHANGED
@@ -27,6 +27,17 @@ export function formatCompact(
27
27
  }
28
28
 
29
29
  const lines = rows.map((row) => {
30
+ if (row.source === "note" && row.note_kind) {
31
+ const prefix = `[${row.note_kind}]`;
32
+ const summary = truncate(row.summary || "", SUMMARY_MAX);
33
+ return `${row.name} · ${row.note_kind} · ${prefix} · ${summary}`;
34
+ }
35
+ if (row.source === "lib" && row.lib && row.version) {
36
+ const origin = `[${row.lib}@${row.version}]`;
37
+ const summary = truncate(row.summary || "", SUMMARY_MAX);
38
+ const loc = row.file_path ? `${basename(row.file_path)}:${row.line_start}-${row.line_end}` : `lib`;
39
+ return `${row.name} · ${row.kind} · ${origin} · ${loc} · ${summary}`;
40
+ }
30
41
  const loc = `${basename(row.file_path)}:${row.line_start}-${row.line_end}`;
31
42
  const summary = truncate(row.summary || "", SUMMARY_MAX);
32
43
  return `${row.name} · ${row.kind} · ${loc} · ${summary}`;
@@ -58,10 +58,35 @@ describe('index.ts contract', () => {
58
58
  expect(queryTool).toBeDefined();
59
59
  expect(queryTool!.description).toContain('code index');
60
60
 
61
+ // Check new v1.3 tools
62
+ const enrichTool = stub.tools.find(t => t.name === 'codewalker_enrich');
63
+ expect(enrichTool).toBeDefined();
64
+ expect(enrichTool!.description).toContain('summary');
65
+
66
+ const noteTool = stub.tools.find(t => t.name === 'codewalker_note');
67
+ expect(noteTool).toBeDefined();
68
+ expect(noteTool!.description).toContain('glossary');
69
+
70
+ // Check enrich tool parameters
71
+ const enrichParams = (enrichTool!.parameters as any);
72
+ expect(enrichParams.properties).toHaveProperty('card');
73
+ expect(enrichParams.properties).toHaveProperty('summary');
74
+
75
+ // Check note tool parameters
76
+ const noteParams = (noteTool!.parameters as any);
77
+ expect(noteParams.properties).toHaveProperty('type');
78
+ expect(noteParams.properties).toHaveProperty('title');
79
+ expect(noteParams.properties).toHaveProperty('body');
80
+
81
+ // Check tool has a source parameter
82
+ const toolParams = (queryTool!.parameters as any);
83
+ expect(toolParams.properties).toHaveProperty('source');
84
+
61
85
  // Check command registered
62
86
  const cmd = stub.commands.find(c => c.name === 'codewalker');
63
87
  expect(cmd).toBeDefined();
64
- expect(cmd!.description).toBeDefined();
88
+ expect(cmd!.description).toContain('libs');
89
+ expect(cmd!.description).toContain('lib');
65
90
  });
66
91
 
67
92
  it('tool.execute returns { content, details } with compact text content', async () => {
@@ -97,4 +122,55 @@ describe('index.ts contract', () => {
97
122
 
98
123
  fs.rmSync(tmpDir, { recursive: true, force: true });
99
124
  });
125
+
126
+ it('tool.execute with source="libs" returns valid result even with no lib data', async () => {
127
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-contract-libs-'));
128
+ const piDir = path.join(tmpDir, '.pi');
129
+ fs.mkdirSync(piDir, { recursive: true });
130
+ const markerId = 'test-project-libs-' + Math.random().toString(36).slice(2, 8);
131
+ fs.writeFileSync(path.join(piDir, markerId + '.md'), `---\npi-project: true\nid: ${markerId}\n---\n`);
132
+
133
+ const homePi = path.join(os.homedir(), '.pi', 'projects', markerId, 'codewalker');
134
+ fs.mkdirSync(homePi, { recursive: true });
135
+
136
+ const mod = await import('./index.ts');
137
+ const stub = createPiStub();
138
+ mod.default(stub.api as any);
139
+
140
+ const tool = stub.tools.find(t => t.name === 'codewalker_query')!;
141
+ const result = await tool.execute(
142
+ 'test-id',
143
+ { query: 'test', source: 'libs' },
144
+ new AbortController().signal,
145
+ () => {},
146
+ { cwd: tmpDir },
147
+ );
148
+
149
+ expect(result).toHaveProperty('content');
150
+ expect(result.content[0]!.text).toContain('No matches');
151
+ expect(result.details.rows).toEqual([]);
152
+
153
+ fs.rmSync(tmpDir, { recursive: true, force: true });
154
+ });
155
+
156
+ it('command description mentions libs and lib subcommands', async () => {
157
+ const mod = await import('./index.ts');
158
+ const stub = createPiStub();
159
+ mod.default(stub.api as any);
160
+
161
+ const cmd = stub.commands.find(c => c.name === 'codewalker')!;
162
+ expect(cmd.description).toContain('libs [--dev]');
163
+ expect(cmd.description).toContain('lib <pkg>');
164
+ });
165
+
166
+ it('command description includes enrich, glossary, decisions subcommands', async () => {
167
+ const mod = await import('./index.ts');
168
+ const stub = createPiStub();
169
+ mod.default(stub.api as any);
170
+
171
+ const cmd = stub.commands.find(c => c.name === 'codewalker')!;
172
+ expect(cmd.description).toContain('enrich');
173
+ expect(cmd.description).toContain('glossary');
174
+ expect(cmd.description).toContain('decisions');
175
+ });
100
176
  });
package/src/index.ts CHANGED
@@ -4,38 +4,52 @@
4
4
  * Queryable, token-economical project & code index for the pi coding agent.
5
5
  *
6
6
  * Registers:
7
- * - `codewalker_query` tool (agent-facing, compact results)
8
- * - `/codewalker` command (human-facing) with subcommands scan, sync, query
7
+ * - `codewalker_query` tool (agent-facing, compact results) — now with `source` param
8
+ * - `/codewalker` command (human-facing) with subcommands scan, sync, query, libs, lib
9
9
  */
10
10
 
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
11
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
14
  import { Type } from "typebox";
13
15
  import { resolveProject, ensureProject } from "./project.ts";
14
16
  import { runQuery } from "./query.ts";
15
17
  import { scan, sync } from "./indexer.ts";
16
- import { formatCompact, formatCardBody } from "./format.ts";
18
+ import { indexLibraries } from "./libs/indexer.ts";
19
+ import { formatCompact } from "./format.ts";
20
+ import { openDb, selectUnenrichedSymbols, updateSymbolSummary, searchNotes, upsertNote } from "./db.ts";
21
+ import { updateCardSummary } from "./cards.ts";
22
+ import { addNote } from "./notes.ts";
23
+ import { formatEnrichWorklist, validateEnrichPath, checkEnrichCap } from "./enrich.ts";
24
+ import type { NoteKind } from "./types.ts";
17
25
 
18
26
  export default function codewalkerExtension(pi: ExtensionAPI): void {
19
- // ----------------------------------------------------------------- tool
27
+ // ----------------------------------------------------------------- tools
28
+
29
+ // -- codewalker_query (v1.1 — extended with source='notes'|'all')
20
30
  pi.registerTool({
21
31
  name: "codewalker_query",
22
32
  label: "Codewalker Query",
23
33
  description:
24
34
  "Search the project's code index for symbols (functions, consts, classes, types). " +
25
- "Returns compact facts (name, kind, file:line, one-line summary) — use this BEFORE grepping/reading files.",
35
+ "Returns compact facts (name, kind, file:line, one-line summary) — use this BEFORE grepping/reading files. " +
36
+ "Optionally search libraries (source='libs') or notes/glossary/decisions (source='notes') or both (source='all').",
26
37
  parameters: Type.Object({
27
38
  query: Type.String({ description: "Search text — symbol name or concept keywords." }),
28
- kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum" })),
39
+ kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum|glossary|decision" })),
29
40
  limit: Type.Optional(Type.Number({ description: "Max hits (default 10)." })),
41
+ source: Type.Optional(Type.String({ description: "Where to search: code | libs | notes | all (default code)." })),
30
42
  }),
31
43
  async execute(_id, params, _signal, _onUpdate, ctx) {
44
+ const p = params as any;
32
45
  const project = resolveProject(ctx.cwd);
33
46
  const { rows, staleness } = runQuery(
34
47
  project.dbPath,
35
48
  {
36
- query: (params as any).query as string,
37
- kind: (params as any).kind as string | undefined,
38
- limit: (params as any).limit as number | undefined,
49
+ query: p.query as string,
50
+ kind: p.kind as string | undefined,
51
+ limit: p.limit as number | undefined,
52
+ source: (p.source as "code" | "libs" | "notes" | "all") ?? "code",
39
53
  },
40
54
  project.root,
41
55
  );
@@ -48,14 +62,128 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
48
62
  },
49
63
  });
50
64
 
65
+ // -- codewalker_enrich (v1.3 — write a semantic summary back to a symbol)
66
+ pi.registerTool({
67
+ name: "codewalker_enrich",
68
+ label: "Codewalker Enrich",
69
+ description:
70
+ "Write a one-line semantic summary back to a symbol's card and DB index. " +
71
+ "Call this AFTER reading the symbol's source span. The summary (≤120 chars) " +
72
+ "is cached so future queries surface meaning, not just names.",
73
+ parameters: Type.Object({
74
+ card: Type.String({ description: "card_path of the symbol (from the enrich worklist)." }),
75
+ summary: Type.String({ description: "One-line (≤120 char) plain-English summary of what it does." }),
76
+ }),
77
+ async execute(_id, params, _signal, _onUpdate, ctx) {
78
+ const p = params as any;
79
+ const card = p.card as string;
80
+ const summary = p.summary as string;
81
+ const project = resolveProject(ctx.cwd);
82
+
83
+ // Resolve the card path (may be absolute or relative to codewalker dir)
84
+ let cardPath = card;
85
+ if (!path.isAbsolute(cardPath)) {
86
+ cardPath = path.resolve(project.codewalkerDir, card);
87
+ }
88
+
89
+ // Check the card file exists
90
+ if (!fs.existsSync(cardPath)) {
91
+ return {
92
+ content: [{ type: "text" as const, text: `No card file found at ${cardPath}.` }],
93
+ details: { error: "card_not_found" },
94
+ };
95
+ }
96
+
97
+ // Read and update the card
98
+ const cardContent = fs.readFileSync(cardPath, "utf-8");
99
+ const updated = updateCardSummary(cardContent, summary);
100
+
101
+ // Atomic write back to the card file
102
+ const tmpPath = cardPath + ".tmp";
103
+ fs.writeFileSync(tmpPath, updated, { encoding: "utf-8", mode: 0o600 });
104
+ fs.renameSync(tmpPath, cardPath);
105
+
106
+ // Update DB
107
+ const db = openDb(project.dbPath);
108
+ let updatedRow = false;
109
+ try {
110
+ updatedRow = updateSymbolSummary(db, cardPath, summary);
111
+ } finally {
112
+ db.close();
113
+ }
114
+
115
+ if (!updatedRow) {
116
+ return {
117
+ content: [{ type: "text" as const, text: `Card ${path.basename(cardPath)} updated but no matching symbol row found in DB. Run /codewalker scan first.` }],
118
+ details: { card_updated: true, db_updated: false },
119
+ };
120
+ }
121
+
122
+ return {
123
+ content: [{ type: "text" as const, text: `Summary written to ${path.basename(cardPath)} and indexed.` }],
124
+ details: { card_updated: true, db_updated: true, card_path: cardPath, summary },
125
+ };
126
+ },
127
+ });
128
+
129
+ // -- codewalker_note (v1.3 — write a glossary term or decision)
130
+ pi.registerTool({
131
+ name: "codewalker_note",
132
+ label: "Codewalker Note",
133
+ description:
134
+ "Write a glossary term or decision note. Persists to a markdown card " +
135
+ "under entries/{glossary,decisions}/ and the FTS index. Future queries " +
136
+ "will surface this conceptual knowledge alongside code symbols.",
137
+ parameters: Type.Object({
138
+ type: Type.String({ description: "glossary | decision" }),
139
+ title: Type.String({ description: "Glossary term, or decision title." }),
140
+ body: Type.String({ description: "The definition, or the decision + rationale." }),
141
+ tags: Type.Optional(Type.String({ description: "Comma-separated tags." })),
142
+ related: Type.Optional(Type.String({ description: "Comma-separated symbol names or file:line refs." })),
143
+ }),
144
+ async execute(_id, params, _signal, _onUpdate, ctx) {
145
+ const p = params as any;
146
+ const type = (p.type as string).toLowerCase();
147
+
148
+ if (type !== "glossary" && type !== "decision") {
149
+ return {
150
+ content: [{ type: "text" as const, text: `Invalid note type "${type}". Must be "glossary" or "decision".` }],
151
+ details: { error: "invalid_type" },
152
+ };
153
+ }
154
+
155
+ const project = await ensureProject(ctx.cwd);
156
+ const notesDir = type === "glossary" ? project.glossaryDir : project.decisionsDir;
157
+
158
+ addNote(project.dbPath, {
159
+ note_kind: type as NoteKind,
160
+ title: (p.title as string).trim(),
161
+ body: (p.body as string).trim(),
162
+ tags: (p.tags as string ?? "").trim(),
163
+ related: (p.related as string ?? "").trim(),
164
+ card_path: "",
165
+ }, notesDir);
166
+
167
+ return {
168
+ content: [{ type: "text" as const, text: `${type === "glossary" ? "Glossary term" : "Decision"} "${p.title}" saved and indexed.` }],
169
+ details: { type, title: p.title },
170
+ };
171
+ },
172
+ });
173
+
51
174
  // ----------------------------------------------------------------- command
52
175
  pi.registerCommand("codewalker", {
53
176
  description:
54
- "codewalker: scan | sync | query <text> | help\n" +
55
- " scan Full (re)build of the code index\n" +
56
- " sync Git-anchored incremental update\n" +
57
- " query <text> Search the index for symbols\n" +
58
- " help Show this help",
177
+ "codewalker: scan | sync | query <text> | enrich <path> [--max=N] | glossary [query] | decisions [query] | libs [--dev] | lib <pkg> [query] | help\n" +
178
+ " scan Full (re)build of the code index\n" +
179
+ " sync Git-anchored incremental update\n" +
180
+ " query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
181
+ " enrich <path> Select unenriched symbols under <path> and write summaries\n" +
182
+ " glossary [query] Search glossary terms\n" +
183
+ " decisions [query] Search decision notes\n" +
184
+ " libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
185
+ " lib <pkg> [query] Search a specific library's API symbols\n" +
186
+ " help Show this help",
59
187
  handler: async (args, ctx) => {
60
188
  const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
61
189
  const sub = tokens[0] ?? "help";
@@ -106,13 +234,139 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
106
234
  break;
107
235
  }
108
236
 
237
+ // ── v1.3: enrich ───────────────────────────────────
238
+ case "enrich": {
239
+ const enrichPath = tokens[1];
240
+ const pathCheck = validateEnrichPath(enrichPath);
241
+ if (!pathCheck.valid) {
242
+ notify(pathCheck.error!, "error");
243
+ return;
244
+ }
245
+
246
+ // Parse optional --max=N
247
+ const maxToken = tokens.find(t => t.startsWith("--max="));
248
+ const cap = maxToken ? parseInt(maxToken.slice(6), 10) : 40;
249
+
250
+ // Select unenriched symbols
251
+ const db = openDb(project.dbPath);
252
+ let symbols;
253
+ try {
254
+ symbols = selectUnenrichedSymbols(db, enrichPath!, cap + 1); // get one extra to detect overflow
255
+ } finally {
256
+ db.close();
257
+ }
258
+
259
+ // Cap check
260
+ const capCheck = checkEnrichCap(symbols.length, cap);
261
+ if (!capCheck.ok) {
262
+ notify(capCheck.error!, "error");
263
+ return;
264
+ }
265
+
266
+ if (symbols.length === 0) {
267
+ notify(`No unenriched symbols found under "${enrichPath}".`);
268
+ return;
269
+ }
270
+
271
+ // Format the worklist
272
+ const worklist = formatEnrichWorklist(symbols, enrichPath!);
273
+
274
+ // With UI, drive the agent; without UI, print the worklist
275
+ if ((ctx as any).hasUI) {
276
+ notify(worklist);
277
+ try {
278
+ (ctx as any).sendUserMessage?.(worklist);
279
+ } catch {
280
+ // sendUserMessage may not be available in all contexts
281
+ }
282
+ } else {
283
+ console.log(worklist);
284
+ }
285
+ break;
286
+ }
287
+
288
+ // ── v1.3: glossary ─────────────────────────────────
289
+ case "glossary": {
290
+ const q = tokens.slice(1).join(" ");
291
+ const db = openDb(project.dbPath);
292
+ let rows;
293
+ try {
294
+ rows = searchNotes(db, q || "", "glossary", 20);
295
+ } finally {
296
+ db.close();
297
+ }
298
+ const text = formatCompact(rows as any, null);
299
+ notify(text || "No glossary terms found.");
300
+ break;
301
+ }
302
+
303
+ // ── v1.3: decisions ────────────────────────────────
304
+ case "decisions": {
305
+ const q = tokens.slice(1).join(" ");
306
+ const db = openDb(project.dbPath);
307
+ let rows;
308
+ try {
309
+ rows = searchNotes(db, q || "", "decision", 20);
310
+ } finally {
311
+ db.close();
312
+ }
313
+ const text = formatCompact(rows as any, null);
314
+ notify(text || "No decision notes found.");
315
+ break;
316
+ }
317
+
318
+ case "libs": {
319
+ const includeDev = tokens.includes("--dev");
320
+ notify(`Indexing libraries${includeDev ? " (including devDependencies)" : ""}…`);
321
+ const result = await indexLibraries({
322
+ projectRoot: project.root,
323
+ libsDir: project.libsDir,
324
+ dbPath: project.dbPath,
325
+ includeDev,
326
+ });
327
+ if (result.indexed === 0 && result.symbols === 0) {
328
+ notify("No libraries indexed. Ensure node_modules exists and has dependencies installed.");
329
+ } else {
330
+ notify(`Indexed ${result.indexed} libraries, ${result.symbols} symbols${result.errors > 0 ? ` (${result.errors} errors)` : ""}.`);
331
+ }
332
+ break;
333
+ }
334
+
335
+ case "lib": {
336
+ const pkg = tokens[1];
337
+ if (!pkg) {
338
+ notify("Usage: /codewalker lib <pkg> [query]", "error");
339
+ return;
340
+ }
341
+ const q = tokens.slice(2).join(" ");
342
+ const { rows, staleness } = runQuery(
343
+ project.dbPath,
344
+ { query: q || "", source: "libs", limit: 20 },
345
+ project.root,
346
+ );
347
+ // Filter to the requested package
348
+ const pkgRows = rows.filter(r => r.lib === pkg || r.name === pkg);
349
+ if (pkgRows.length === 0) {
350
+ notify(`No API symbols found for "${pkg}". Run /codewalker libs first to index libraries.`);
351
+ } else {
352
+ const text = formatCompact(pkgRows, staleness);
353
+ notify(text);
354
+ }
355
+ break;
356
+ }
357
+
109
358
  default: {
110
359
  notify(
111
- "codewalker: scan | sync | query <text> | help\n" +
112
- " scan Full (re)build of the code index\n" +
113
- " sync Git-anchored incremental update\n" +
114
- " query <text> Search the index for symbols\n" +
115
- " help Show this help",
360
+ "codewalker: scan | sync | query <text> | enrich <path> [--max=N] | glossary [query] | decisions [query] | libs [--dev] | lib <pkg> [query] | help\n" +
361
+ " scan Full (re)build of the code index\n" +
362
+ " sync Git-anchored incremental update\n" +
363
+ " query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
364
+ " enrich <path> Select unenriched symbols under <path> and write summaries\n" +
365
+ " glossary [query] Search glossary terms\n" +
366
+ " decisions [query] Search decision notes\n" +
367
+ " libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
368
+ " lib <pkg> [query] Search a specific library's API symbols\n" +
369
+ " help Show this help",
116
370
  );
117
371
  }
118
372
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Regression test for the "database disk image is malformed" crash on `/codewalker scan`.
3
+ *
4
+ * A DB written by an older (pre-trigger, manual-FTS-sync) build can have a `symbols_fts`
5
+ * external-content index that is silently out of sync with the `symbols` table. v1.3's bootstrap
6
+ * adds the FTS-sync triggers, but those don't reconcile the already-stale index — so the per-row
7
+ * DELETEs in scan() fire `symbols_ad` 'delete' commands against mismatched `old.*` values and
8
+ * corrupt the index. scan()/sync() guard against this by calling rebuildFtsIndexes() first, which
9
+ * re-derives every `*_fts` from its content table (the FTS5 'rebuild' command).
10
+ *
11
+ * The exact on-disk corruption is b-tree-state dependent and not portably synthesizable, so this
12
+ * test pins the *fix mechanism*: rebuildFtsIndexes() reconciles a deliberately mismatched FTS.
13
+ */
14
+
15
+ import { describe, it, expect } from "vitest";
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { openDb, rebuildFtsIndexes } from "./db.ts";
20
+ import { scan } from "./indexer.ts";
21
+
22
+ function tmpDir(): string {
23
+ return fs.mkdtempSync(path.join(os.tmpdir(), "cw-heal-"));
24
+ }
25
+
26
+ function matchCount(db: ReturnType<typeof openDb>, table: string, term: string): number {
27
+ return (db.prepare(`SELECT count(*) c FROM ${table} WHERE ${table} MATCH ?`).get(term) as { c: number }).c;
28
+ }
29
+
30
+ describe("rebuildFtsIndexes heals a stale/mismatched FTS index", () => {
31
+ it("makes content searchable and drops stale tokens for all three FTS tables", () => {
32
+ const dir = tmpDir();
33
+ const db = openDb(path.join(dir, "index.db"));
34
+
35
+ // Seed base-table rows directly via FTS-bypassing inserts would still fire triggers, so to
36
+ // simulate a *stale* index we insert the base row, then overwrite the FTS shadow with wrong
37
+ // tokens (as a pre-trigger build would have left it).
38
+ db.prepare(
39
+ "INSERT INTO symbols (name,kind,file_path,line_start,line_end,signature,doc,summary,card_path) VALUES (?,?,?,?,?,?,?,?,?)",
40
+ ).run("realSymbol", "function", "/a.ts", 1, 2, "sig", "doc", "", "/c.md");
41
+ db.prepare(
42
+ "INSERT INTO lib_symbols (lib,version,name,kind,signature,doc,summary,card_path) VALUES (?,?,?,?,?,?,?,?)",
43
+ ).run("hono", "1.0.0", "realLibSymbol", "function", "sig", "doc", "", "/c.md");
44
+ db.prepare(
45
+ "INSERT INTO notes (note_kind,title,body,tags,related,card_path,created_at) VALUES (?,?,?,?,?,?,?)",
46
+ ).run("glossary", "realTerm", "body", "", "", "/c.md", "now");
47
+
48
+ // Corrupt the shadow indexes: replace the synced tokens with bogus ones.
49
+ db.exec("INSERT INTO symbols_fts(symbols_fts) VALUES('delete-all')");
50
+ db.exec("INSERT INTO lib_symbols_fts(lib_symbols_fts) VALUES('delete-all')");
51
+ db.exec("INSERT INTO notes_fts(notes_fts) VALUES('delete-all')");
52
+ db.prepare("INSERT INTO symbols_fts(rowid,name,signature,doc,summary) VALUES (1,?,?,?,?)").run("WRONG", "x", "y", "z");
53
+ db.prepare("INSERT INTO lib_symbols_fts(rowid,name,signature,doc,summary) VALUES (1,?,?,?,?)").run("WRONG", "x", "y", "z");
54
+ db.prepare("INSERT INTO notes_fts(rowid,title,body,tags) VALUES (1,?,?,?)").run("WRONG", "y", "z");
55
+
56
+ // Stale precondition: real content not findable, bogus token is.
57
+ expect(matchCount(db, "symbols_fts", "realSymbol")).toBe(0);
58
+ expect(matchCount(db, "symbols_fts", "WRONG")).toBe(1);
59
+
60
+ rebuildFtsIndexes(db);
61
+
62
+ // After heal: real content searchable, bogus tokens gone — across all three tables.
63
+ expect(matchCount(db, "symbols_fts", "realSymbol")).toBe(1);
64
+ expect(matchCount(db, "symbols_fts", "WRONG")).toBe(0);
65
+ expect(matchCount(db, "lib_symbols_fts", "realLibSymbol")).toBe(1);
66
+ expect(matchCount(db, "lib_symbols_fts", "WRONG")).toBe(0);
67
+ expect(matchCount(db, "notes_fts", "realTerm")).toBe(1);
68
+ expect(matchCount(db, "notes_fts", "WRONG")).toBe(0);
69
+
70
+ db.close();
71
+ });
72
+
73
+ it("scan() runs the heal and leaves symbols_fts consistent with symbols", async () => {
74
+ const dir = tmpDir();
75
+ const dbPath = path.join(dir, "index.db");
76
+ const entriesDir = path.join(dir, "entries");
77
+ const symbolsDir = path.join(entriesDir, "symbols");
78
+ fs.mkdirSync(symbolsDir, { recursive: true });
79
+
80
+ await scan({ projectRoot: process.cwd(), globalCodewalkerDir: dir, dbPath, entriesDir, symbolsDir });
81
+
82
+ const db = openDb(dbPath);
83
+ const symbols = (db.prepare("SELECT count(*) c FROM symbols").get() as { c: number }).c;
84
+ const fts = (db.prepare("SELECT count(*) c FROM symbols_fts").get() as { c: number }).c;
85
+ db.close();
86
+
87
+ expect(symbols).toBeGreaterThan(0);
88
+ expect(fts).toBe(symbols);
89
+ });
90
+ });