@aprimediet/codewalker 1.2.0 → 1.4.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/index.ts CHANGED
@@ -8,6 +8,8 @@
8
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";
@@ -15,21 +17,32 @@ import { runQuery } from "./query.ts";
15
17
  import { scan, sync } from "./indexer.ts";
16
18
  import { indexLibraries } from "./libs/indexer.ts";
17
19
  import { formatCompact } from "./format.ts";
20
+ import { openDb, selectUnenrichedSymbols, updateSymbolSummary, searchNotes, upsertNote, upsertFinding, searchFindings, deleteFindingsForFile } 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 { runAnalyze, collectSourceFiles } from "./analyze/analyzer.ts";
25
+ import { renderAnalysisCard } from "./analyze/cards.ts";
26
+ import { validateReviewPath, checkReviewCap, selectFilesForReview, formatReviewWorklist } from "./analyze/review.ts";
27
+ import type { NoteKind } from "./types.ts";
18
28
 
19
29
  export default function codewalkerExtension(pi: ExtensionAPI): void {
20
- // ----------------------------------------------------------------- tool
30
+ // ----------------------------------------------------------------- tools
31
+
32
+ // -- codewalker_query (v1.1 — extended with source='notes'|'all')
21
33
  pi.registerTool({
22
34
  name: "codewalker_query",
23
35
  label: "Codewalker Query",
24
36
  description:
25
37
  "Search the project's code index for symbols (functions, consts, classes, types). " +
26
38
  "Returns compact facts (name, kind, file:line, one-line summary) — use this BEFORE grepping/reading files. " +
27
- "Optionally search libraries (source='libs') or both (source='all').",
39
+ "Optionally search libraries (source='libs') or notes/glossary/decisions (source='notes') or " +
40
+ "analysis findings (source='analysis') or all sources (source='all').",
28
41
  parameters: Type.Object({
29
42
  query: Type.String({ description: "Search text — symbol name or concept keywords." }),
30
- kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum" })),
43
+ kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum|glossary|decision" })),
31
44
  limit: Type.Optional(Type.Number({ description: "Max hits (default 10)." })),
32
- source: Type.Optional(Type.String({ description: "Where to search: code | libs | all (default code)." })),
45
+ source: Type.Optional(Type.String({ description: "Where to search: code | libs | notes | all (default code)." })),
33
46
  }),
34
47
  async execute(_id, params, _signal, _onUpdate, ctx) {
35
48
  const p = params as any;
@@ -40,7 +53,7 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
40
53
  query: p.query as string,
41
54
  kind: p.kind as string | undefined,
42
55
  limit: p.limit as number | undefined,
43
- source: (p.source as "code" | "libs" | "all") ?? "code",
56
+ source: (p.source as "code" | "libs" | "notes" | "all") ?? "code",
44
57
  },
45
58
  project.root,
46
59
  );
@@ -53,13 +66,227 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
53
66
  },
54
67
  });
55
68
 
69
+ // -- codewalker_enrich (v1.3 — write a semantic summary back to a symbol)
70
+ pi.registerTool({
71
+ name: "codewalker_enrich",
72
+ label: "Codewalker Enrich",
73
+ description:
74
+ "Write a one-line semantic summary back to a symbol's card and DB index. " +
75
+ "Call this AFTER reading the symbol's source span. The summary (≤120 chars) " +
76
+ "is cached so future queries surface meaning, not just names.",
77
+ parameters: Type.Object({
78
+ card: Type.String({ description: "card_path of the symbol (from the enrich worklist)." }),
79
+ summary: Type.String({ description: "One-line (≤120 char) plain-English summary of what it does." }),
80
+ }),
81
+ async execute(_id, params, _signal, _onUpdate, ctx) {
82
+ const p = params as any;
83
+ const card = p.card as string;
84
+ const summary = p.summary as string;
85
+ const project = resolveProject(ctx.cwd);
86
+
87
+ // Resolve the card path (may be absolute or relative to codewalker dir)
88
+ let cardPath = card;
89
+ if (!path.isAbsolute(cardPath)) {
90
+ cardPath = path.resolve(project.codewalkerDir, card);
91
+ }
92
+
93
+ // Check the card file exists
94
+ if (!fs.existsSync(cardPath)) {
95
+ return {
96
+ content: [{ type: "text" as const, text: `No card file found at ${cardPath}.` }],
97
+ details: { error: "card_not_found" },
98
+ };
99
+ }
100
+
101
+ // Read and update the card
102
+ const cardContent = fs.readFileSync(cardPath, "utf-8");
103
+ const updated = updateCardSummary(cardContent, summary);
104
+
105
+ // Atomic write back to the card file
106
+ const tmpPath = cardPath + ".tmp";
107
+ fs.writeFileSync(tmpPath, updated, { encoding: "utf-8", mode: 0o600 });
108
+ fs.renameSync(tmpPath, cardPath);
109
+
110
+ // Update DB
111
+ const db = openDb(project.dbPath);
112
+ let updatedRow = false;
113
+ try {
114
+ updatedRow = updateSymbolSummary(db, cardPath, summary);
115
+ } finally {
116
+ db.close();
117
+ }
118
+
119
+ if (!updatedRow) {
120
+ return {
121
+ content: [{ type: "text" as const, text: `Card ${path.basename(cardPath)} updated but no matching symbol row found in DB. Run /codewalker scan first.` }],
122
+ details: { card_updated: true, db_updated: false },
123
+ };
124
+ }
125
+
126
+ return {
127
+ content: [{ type: "text" as const, text: `Summary written to ${path.basename(cardPath)} and indexed.` }],
128
+ details: { card_updated: true, db_updated: true, card_path: cardPath, summary },
129
+ };
130
+ },
131
+ });
132
+
133
+ // -- codewalker_note (v1.3 — write a glossary term, decision, or convention)
134
+ pi.registerTool({
135
+ name: "codewalker_note",
136
+ label: "Codewalker Note",
137
+ description:
138
+ "Write a glossary term, decision, or convention note. Persists to a markdown card " +
139
+ "under entries/{glossary,decisions,conventions}/ and the FTS index. Future queries " +
140
+ "will surface this conceptual knowledge alongside code symbols.",
141
+ parameters: Type.Object({
142
+ type: Type.String({ description: "glossary | decision | convention" }),
143
+ title: Type.String({ description: "Glossary term, decision title, or convention name." }),
144
+ body: Type.String({ description: "The definition, decision + rationale, or coding convention." }),
145
+ tags: Type.Optional(Type.String({ description: "Comma-separated tags." })),
146
+ related: Type.Optional(Type.String({ description: "Comma-separated symbol names or file:line refs." })),
147
+ }),
148
+ async execute(_id, params, _signal, _onUpdate, ctx) {
149
+ const p = params as any;
150
+ const type = (p.type as string).toLowerCase();
151
+
152
+ if (type !== "glossary" && type !== "decision" && type !== "convention") {
153
+ return {
154
+ content: [{ type: "text" as const, text: `Invalid note type "${type}". Must be "glossary", "decision", or "convention".` }],
155
+ details: { error: "invalid_type" },
156
+ };
157
+ }
158
+
159
+ const project = await ensureProject(ctx.cwd);
160
+ let notesDir: string;
161
+ if (type === "glossary") notesDir = project.glossaryDir;
162
+ else if (type === "decision") notesDir = project.decisionsDir;
163
+ else notesDir = project.conventionsDir;
164
+
165
+ addNote(project.dbPath, {
166
+ note_kind: type as NoteKind,
167
+ title: (p.title as string).trim(),
168
+ body: (p.body as string).trim(),
169
+ tags: (p.tags as string ?? "").trim(),
170
+ related: (p.related as string ?? "").trim(),
171
+ card_path: "",
172
+ }, notesDir);
173
+
174
+ const kindLabel = type === "glossary" ? "Glossary term" : type === "decision" ? "Decision" : "Convention";
175
+ return {
176
+ content: [{ type: "text" as const, text: `${kindLabel} "${p.title}" saved and indexed.` }],
177
+ details: { type, title: p.title },
178
+ };
179
+ },
180
+ });
181
+
182
+ // -- codewalker_finding (v1.4 — write an analysis finding)
183
+ pi.registerTool({
184
+ name: "codewalker_finding",
185
+ label: "Codewalker Finding",
186
+ description:
187
+ "Write an analysis finding (coverage, debt, or best-practice). " +
188
+ "Persists to a markdown card under entries/analysis/<kind>/ and the FTS index. " +
189
+ "Future queries will surface this finding alongside code symbols. " +
190
+ "Use kind='practice' for agent-driven best-practice findings.",
191
+ parameters: Type.Object({
192
+ kind: Type.String({ description: "coverage | debt | practice" }),
193
+ title: Type.String({ description: "Short finding label." }),
194
+ file: Type.Optional(Type.String({ description: "File or file:line the finding is about." })),
195
+ severity: Type.Optional(Type.String({ description: "info | warn | high (default 'info')." })),
196
+ body: Type.String({ description: "The finding detail + why it matters, grounded in conventions/decisions." }),
197
+ metric: Type.Optional(Type.String({ description: "Optional metric string, e.g. '42%', 'fn length 180'." })),
198
+ related: Type.Optional(Type.String({ description: "Comma-separated symbol names or file:line refs." })),
199
+ }),
200
+ async execute(_id, params, _signal, _onUpdate, ctx) {
201
+ const p = params as any;
202
+ const kind = (p.kind as string).toLowerCase();
203
+
204
+ if (!["coverage", "debt", "practice"].includes(kind)) {
205
+ return {
206
+ content: [{ type: "text" as const, text: `Invalid kind "${kind}". Must be coverage, debt, or practice.` }],
207
+ details: { error: "invalid_kind" },
208
+ };
209
+ }
210
+
211
+ const validSeverities = ["info", "warn", "high"];
212
+ const severity = (p.severity as string ?? "info").toLowerCase();
213
+ if (!validSeverities.includes(severity)) {
214
+ return {
215
+ content: [{ type: "text" as const, text: `Invalid severity "${severity}". Must be info, warn, or high.` }],
216
+ details: { error: "invalid_severity" },
217
+ };
218
+ }
219
+
220
+ // Parse file/file:line into file_path, line_start
221
+ let filePath = (p.file as string ?? "").trim();
222
+ let lineStart = 0;
223
+ const locMatch = filePath.match(/^(.+):(\d+)$/);
224
+ if (locMatch) {
225
+ filePath = locMatch[1]!;
226
+ lineStart = parseInt(locMatch[2]!, 10);
227
+ }
228
+
229
+ const project = await ensureProject(ctx.cwd);
230
+
231
+ const finding = {
232
+ finding_kind: kind as "coverage" | "debt" | "practice",
233
+ title: (p.title as string).trim(),
234
+ severity,
235
+ file_path: filePath,
236
+ line_start: lineStart,
237
+ line_end: lineStart, // when only line_start known
238
+ metric: (p.metric as string) ?? "",
239
+ body: (p.body as string).trim(),
240
+ related: (p.related as string ?? "").trim(),
241
+ };
242
+
243
+ // Render and write card
244
+ const kindDir = path.join(project.analysisDir, kind);
245
+ if (!fs.existsSync(kindDir)) {
246
+ fs.mkdirSync(kindDir, { recursive: true });
247
+ }
248
+
249
+ const slug = finding.title
250
+ .toLowerCase()
251
+ .replace(/[^a-z0-9]+/g, "-")
252
+ .replace(/^-+|-+$/g, "")
253
+ .slice(0, 80) || "finding";
254
+ const cardPath = path.join(kindDir, `${slug}.md`);
255
+ const card = renderAnalysisCard(finding);
256
+
257
+ const tmpPath = cardPath + ".tmp";
258
+ fs.writeFileSync(tmpPath, card, { encoding: "utf-8", mode: 0o600 });
259
+ fs.renameSync(tmpPath, cardPath);
260
+
261
+ // Upsert DB row
262
+ const db = openDb(project.dbPath);
263
+ try {
264
+ upsertFinding(db, { ...finding, card_path: cardPath });
265
+ } finally {
266
+ db.close();
267
+ }
268
+
269
+ return {
270
+ content: [{ type: "text" as const, text: `Finding "${finding.title}" saved.` }],
271
+ details: { kind, title: finding.title, card_path: cardPath },
272
+ };
273
+ },
274
+ });
275
+
56
276
  // ----------------------------------------------------------------- command
57
277
  pi.registerCommand("codewalker", {
58
278
  description:
59
- "codewalker: scan | sync | query <text> | libs [--dev] | lib <pkg> [query] | help\n" +
279
+ "codewalker: scan | sync | query <text> | enrich <path> [--max=N] | analyze [path] | review <path> [--max=N] | findings [query] [--kind=KIND] | conventions [query] | glossary [query] | decisions [query] | libs [--dev] | lib <pkg> [query] | help\n" +
60
280
  " scan Full (re)build of the code index\n" +
61
281
  " sync Git-anchored incremental update\n" +
62
- " query <text> Search the code index for symbols\n" +
282
+ " query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
283
+ " enrich <path> Select unenriched symbols under <path> and write summaries\n" +
284
+ " analyze [path] Mechanical coverage + debt analysis (reads lcov.info/coverage-final.json if present)\n" +
285
+ " review <path> Agent-driven best-practice review against conventions/decisions (capped at 25 files)\n" +
286
+ " findings [query] Search analysis findings (--kind=coverage|debt|practice to filter)\n" +
287
+ " conventions [q] Search coding conventions\n" +
288
+ " glossary [query] Search glossary terms\n" +
289
+ " decisions [query] Search decision notes\n" +
63
290
  " libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
64
291
  " lib <pkg> [query] Search a specific library's API symbols\n" +
65
292
  " help Show this help",
@@ -113,6 +340,87 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
113
340
  break;
114
341
  }
115
342
 
343
+ // ── v1.3: enrich ───────────────────────────────────
344
+ case "enrich": {
345
+ const enrichPath = tokens[1];
346
+ const pathCheck = validateEnrichPath(enrichPath);
347
+ if (!pathCheck.valid) {
348
+ notify(pathCheck.error!, "error");
349
+ return;
350
+ }
351
+
352
+ // Parse optional --max=N
353
+ const maxToken = tokens.find(t => t.startsWith("--max="));
354
+ const cap = maxToken ? parseInt(maxToken.slice(6), 10) : 40;
355
+
356
+ // Select unenriched symbols
357
+ const db = openDb(project.dbPath);
358
+ let symbols;
359
+ try {
360
+ symbols = selectUnenrichedSymbols(db, enrichPath!, cap + 1); // get one extra to detect overflow
361
+ } finally {
362
+ db.close();
363
+ }
364
+
365
+ // Cap check
366
+ const capCheck = checkEnrichCap(symbols.length, cap);
367
+ if (!capCheck.ok) {
368
+ notify(capCheck.error!, "error");
369
+ return;
370
+ }
371
+
372
+ if (symbols.length === 0) {
373
+ notify(`No unenriched symbols found under "${enrichPath}".`);
374
+ return;
375
+ }
376
+
377
+ // Format the worklist
378
+ const worklist = formatEnrichWorklist(symbols, enrichPath!);
379
+
380
+ // With UI, drive the agent; without UI, print the worklist
381
+ if ((ctx as any).hasUI) {
382
+ notify(worklist);
383
+ try {
384
+ (ctx as any).sendUserMessage?.(worklist);
385
+ } catch {
386
+ // sendUserMessage may not be available in all contexts
387
+ }
388
+ } else {
389
+ console.log(worklist);
390
+ }
391
+ break;
392
+ }
393
+
394
+ // ── v1.3: glossary ─────────────────────────────────
395
+ case "glossary": {
396
+ const q = tokens.slice(1).join(" ");
397
+ const db = openDb(project.dbPath);
398
+ let rows;
399
+ try {
400
+ rows = searchNotes(db, q || "", "glossary", 20);
401
+ } finally {
402
+ db.close();
403
+ }
404
+ const text = formatCompact(rows as any, null);
405
+ notify(text || "No glossary terms found.");
406
+ break;
407
+ }
408
+
409
+ // ── v1.3: decisions ────────────────────────────────
410
+ case "decisions": {
411
+ const q = tokens.slice(1).join(" ");
412
+ const db = openDb(project.dbPath);
413
+ let rows;
414
+ try {
415
+ rows = searchNotes(db, q || "", "decision", 20);
416
+ } finally {
417
+ db.close();
418
+ }
419
+ const text = formatCompact(rows as any, null);
420
+ notify(text || "No decision notes found.");
421
+ break;
422
+ }
423
+
116
424
  case "libs": {
117
425
  const includeDev = tokens.includes("--dev");
118
426
  notify(`Indexing libraries${includeDev ? " (including devDependencies)" : ""}…`);
@@ -153,12 +461,122 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
153
461
  break;
154
462
  }
155
463
 
464
+ // ── v1.4: analyze ──────────────────────────────────
465
+ case "analyze": {
466
+ const analyzePath = tokens[1] ?? project.root;
467
+ notify(`Running analysis${analyzePath !== project.root ? ` on ${analyzePath}` : ""}…`);
468
+ const result = runAnalyze({
469
+ projectRoot: project.root,
470
+ analysisDir: project.analysisDir,
471
+ dbPath: project.dbPath,
472
+ pathFilter: analyzePath !== project.root ? analyzePath : undefined,
473
+ });
474
+ const parts: string[] = [];
475
+ if (result.coverage > 0) parts.push(`${result.coverage} coverage`);
476
+ else parts.push("no coverage data (run your coverage tool first)");
477
+ if (result.debt > 0) parts.push(`${result.debt} debt`);
478
+ else parts.push("no debt");
479
+ notify(`Analysis complete: ${parts.join(", ")} finding(s).`);
480
+ break;
481
+ }
482
+
483
+ // ── v1.4: review ───────────────────────────────────
484
+ case "review": {
485
+ const reviewPath = tokens[1];
486
+ const pathCheck = validateReviewPath(reviewPath);
487
+ if (!pathCheck.valid) {
488
+ notify(pathCheck.error!, "error");
489
+ return;
490
+ }
491
+
492
+ // Parse optional --max=N
493
+ const maxToken = tokens.find(t => t.startsWith("--max="));
494
+ const cap = maxToken ? parseInt(maxToken.slice(6), 10) : 25;
495
+
496
+ // Walk source files under the review path
497
+ const allFiles = collectSourceFiles(project.root, reviewPath);
498
+
499
+ // Cap check
500
+ const capCheck = checkReviewCap(allFiles.length, cap);
501
+ if (!capCheck.ok) {
502
+ notify(capCheck.error!, "error");
503
+ return;
504
+ }
505
+
506
+ // Select files (respect cap)
507
+ const selectedFiles = selectFilesForReview(allFiles, reviewPath!, cap);
508
+
509
+ if (selectedFiles.length === 0) {
510
+ notify(`No source files found under "${reviewPath}".`);
511
+ return;
512
+ }
513
+
514
+ // Format the worklist
515
+ const worklist = formatReviewWorklist(selectedFiles, reviewPath!);
516
+
517
+ // With UI, drive the agent; without UI, print the worklist
518
+ if ((ctx as any).hasUI) {
519
+ notify(worklist);
520
+ try {
521
+ (ctx as any).sendUserMessage?.(worklist);
522
+ } catch {
523
+ // sendUserMessage may not be available in all contexts
524
+ }
525
+ } else {
526
+ console.log(worklist);
527
+ }
528
+ break;
529
+ }
530
+
531
+ // ── v1.4: findings ─────────────────────────────────
532
+ case "findings": {
533
+ const q = tokens.slice(1).join(" ");
534
+ // Parse optional --kind=
535
+ const kindToken = tokens.find(t => t.startsWith("--kind="));
536
+ const kindFilter = kindToken ? kindToken.slice(7) : undefined;
537
+ // Strip --kind from the query
538
+ const cleanQuery = tokens.filter(t => !t.startsWith("--")).slice(1).join(" ");
539
+
540
+ const db = openDb(project.dbPath);
541
+ let rows;
542
+ try {
543
+ rows = searchFindings(db, cleanQuery, kindFilter, 20);
544
+ } finally {
545
+ db.close();
546
+ }
547
+ const text = formatCompact(rows as any, null);
548
+ notify(text || "No findings found.");
549
+ break;
550
+ }
551
+
552
+ // ── v1.4: conventions ──────────────────────────────
553
+ case "conventions": {
554
+ const q = tokens.slice(1).join(" ");
555
+ const db = openDb(project.dbPath);
556
+ let rows;
557
+ try {
558
+ rows = searchNotes(db, q || "", "convention", 20);
559
+ } finally {
560
+ db.close();
561
+ }
562
+ const text = formatCompact(rows as any, null);
563
+ notify(text || "No conventions found.");
564
+ break;
565
+ }
566
+
156
567
  default: {
157
568
  notify(
158
- "codewalker: scan | sync | query <text> | libs [--dev] | lib <pkg> [query] | help\n" +
569
+ "codewalker: scan | sync | query <text> | enrich <path> [--max=N] | analyze [path] | review <path> [--max=N] | findings [query] [--kind=KIND] | conventions [query] | glossary [query] | decisions [query] | libs [--dev] | lib <pkg> [query] | help\n" +
159
570
  " scan Full (re)build of the code index\n" +
160
571
  " sync Git-anchored incremental update\n" +
161
- " query <text> Search the code index for symbols\n" +
572
+ " query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
573
+ " enrich <path> Select unenriched symbols under <path> and write summaries\n" +
574
+ " analyze [path] Mechanical coverage + debt analysis (reads lcov.info/coverage-final.json if present)\n" +
575
+ " review <path> Agent-driven best-practice review against conventions/decisions (capped at 25 files)\n" +
576
+ " findings [query] Search analysis findings (--kind=coverage|debt|practice to filter)\n" +
577
+ " conventions [q] Search coding conventions\n" +
578
+ " glossary [query] Search glossary terms\n" +
579
+ " decisions [query] Search decision notes\n" +
162
580
  " libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
163
581
  " lib <pkg> [query] Search a specific library's API symbols\n" +
164
582
  " help Show this help",
@@ -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
+ });
package/src/indexer.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
- import { openDb, upsertSymbol, deleteFileSymbols, deleteFile, setMeta, getMeta } from "./db.ts";
11
+ import { openDb, upsertSymbol, deleteFileSymbols, deleteFile, setMeta, getMeta, rebuildFtsIndexes } from "./db.ts";
12
12
  import { detectCtags, runCtags, runCtagsOnFile } from "./extract/ctags.ts";
13
13
  import { parseCtagsOutput } from "./extract/ctags-parse.ts";
14
14
  import { extractRegex } from "./extract/regex.ts";
@@ -83,6 +83,11 @@ export async function scan(options: ScanOptions): Promise<void> {
83
83
  const db = openDb(dbPath);
84
84
 
85
85
  try {
86
+ // Heal any stale/legacy FTS index before the per-row deletes below fire their triggers.
87
+ // Without this, a DB from an older build can corrupt mid-scan ("database disk image is
88
+ // malformed"). See rebuildFtsIndexes() for the full rationale.
89
+ rebuildFtsIndexes(db);
90
+
86
91
  // Start transaction
87
92
  db.exec("BEGIN TRANSACTION");
88
93
 
@@ -150,6 +155,9 @@ export async function sync(options: ScanOptions): Promise<void> {
150
155
  const db = openDb(dbPath);
151
156
 
152
157
  try {
158
+ // Heal a stale/legacy FTS index before any trigger-driven deletes (see rebuildFtsIndexes).
159
+ rebuildFtsIndexes(db);
160
+
153
161
  const lastCommit = getMeta(db, "last_indexed_commit");
154
162
  let changedFiles: string[] = [];
155
163