@aprimediet/codewalker 1.3.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/db.ts CHANGED
@@ -28,7 +28,7 @@ export function openDb(dbPath: string): DatabaseType {
28
28
  /** Bootstrap DDL — idempotent (all CREATE use IF NOT EXISTS). */
29
29
  export function bootstrapDb(db: DatabaseType): void {
30
30
  db.exec(`
31
- PRAGMA user_version = 3;
31
+ PRAGMA user_version = 4;
32
32
 
33
33
  CREATE TABLE IF NOT EXISTS files (
34
34
  path TEXT PRIMARY KEY,
@@ -155,6 +155,45 @@ export function bootstrapDb(db: DatabaseType): void {
155
155
  INSERT INTO notes_fts(rowid, title, body, tags)
156
156
  VALUES (new.id, new.title, new.body, new.tags);
157
157
  END;
158
+
159
+ -- v1.4: Analysis table for coverage/debt/practice findings
160
+ CREATE TABLE IF NOT EXISTS analysis (
161
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
162
+ finding_kind TEXT NOT NULL,
163
+ title TEXT NOT NULL,
164
+ severity TEXT,
165
+ file_path TEXT,
166
+ line_start INTEGER,
167
+ line_end INTEGER,
168
+ metric TEXT,
169
+ body TEXT,
170
+ related TEXT,
171
+ card_path TEXT,
172
+ created_at TEXT
173
+ );
174
+
175
+ CREATE VIRTUAL TABLE IF NOT EXISTS analysis_fts USING fts5(
176
+ title, body, metric,
177
+ content='analysis', content_rowid='id',
178
+ tokenize='unicode61 remove_diacritics 2'
179
+ );
180
+
181
+ CREATE TRIGGER IF NOT EXISTS analysis_ai AFTER INSERT ON analysis BEGIN
182
+ INSERT INTO analysis_fts(rowid, title, body, metric)
183
+ VALUES (new.id, new.title, new.body, new.metric);
184
+ END;
185
+
186
+ CREATE TRIGGER IF NOT EXISTS analysis_ad AFTER DELETE ON analysis BEGIN
187
+ INSERT INTO analysis_fts(analysis_fts, rowid, title, body, metric)
188
+ VALUES ('delete', old.id, old.title, old.body, old.metric);
189
+ END;
190
+
191
+ CREATE TRIGGER IF NOT EXISTS analysis_au AFTER UPDATE ON analysis BEGIN
192
+ INSERT INTO analysis_fts(analysis_fts, rowid, title, body, metric)
193
+ VALUES ('delete', old.id, old.title, old.body, old.metric);
194
+ INSERT INTO analysis_fts(rowid, title, body, metric)
195
+ VALUES (new.id, new.title, new.body, new.metric);
196
+ END;
158
197
  `);
159
198
  }
160
199
 
@@ -171,6 +210,7 @@ export function rebuildFtsIndexes(db: DatabaseType): void {
171
210
  db.exec("INSERT INTO symbols_fts(symbols_fts) VALUES('rebuild')");
172
211
  db.exec("INSERT INTO lib_symbols_fts(lib_symbols_fts) VALUES('rebuild')");
173
212
  db.exec("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')");
213
+ db.exec("INSERT INTO analysis_fts(analysis_fts) VALUES('rebuild')");
174
214
  }
175
215
 
176
216
  /** Upsert a file record. */
@@ -378,6 +418,156 @@ export function searchNotes(
378
418
  }));
379
419
  }
380
420
 
421
+ // ── Analysis CRUD ────────────────────────────────────────────
422
+
423
+ /**
424
+ * Allowed finding kinds.
425
+ */
426
+ const VALID_FINDING_KINDS = new Set(["coverage", "debt", "practice"]);
427
+
428
+ /**
429
+ * Upsert a finding keyed on (finding_kind, file_path, title).
430
+ * Returns the row id.
431
+ */
432
+ export function upsertFinding(
433
+ db: DatabaseType,
434
+ finding: {
435
+ finding_kind: string;
436
+ title: string;
437
+ severity?: string;
438
+ file_path?: string;
439
+ line_start?: number;
440
+ line_end?: number;
441
+ metric?: string;
442
+ body?: string;
443
+ related?: string;
444
+ card_path?: string;
445
+ },
446
+ ): number {
447
+ if (!VALID_FINDING_KINDS.has(finding.finding_kind)) {
448
+ throw new Error(`Invalid finding_kind "${finding.finding_kind}". Must be coverage, debt, or practice.`);
449
+ }
450
+
451
+ const existing = db.prepare(
452
+ "SELECT id FROM analysis WHERE finding_kind = ? AND file_path = ? AND title = ?",
453
+ ).get(finding.finding_kind, finding.file_path ?? "", finding.title) as { id: number } | undefined;
454
+
455
+ if (existing) {
456
+ db.prepare(
457
+ `UPDATE analysis SET severity=?, line_start=?, line_end=?, metric=?, body=?, related=?, card_path=?, created_at=COALESCE(created_at, datetime('now'))
458
+ WHERE id = ?`,
459
+ ).run(
460
+ finding.severity ?? null, finding.line_start ?? null, finding.line_end ?? null,
461
+ finding.metric ?? null, finding.body ?? null, finding.related ?? null,
462
+ finding.card_path ?? null, existing.id,
463
+ );
464
+ return existing.id;
465
+ }
466
+
467
+ const result = db.prepare(
468
+ `INSERT INTO analysis (finding_kind, title, severity, file_path, line_start, line_end, metric, body, related, card_path, created_at)
469
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
470
+ ).run(
471
+ finding.finding_kind, finding.title, finding.severity ?? null, finding.file_path ?? "",
472
+ finding.line_start ?? null, finding.line_end ?? null, finding.metric ?? null,
473
+ finding.body ?? null, finding.related ?? null, finding.card_path ?? null,
474
+ );
475
+ return Number(result.lastInsertRowid);
476
+ }
477
+
478
+ /**
479
+ * Delete all findings for a given file of a given kind.
480
+ * The analysis_ad trigger removes the FTS rows.
481
+ */
482
+ export function deleteFindingsForFile(
483
+ db: DatabaseType,
484
+ findingKind: string,
485
+ filePath: string,
486
+ ): void {
487
+ db.prepare("DELETE FROM analysis WHERE finding_kind = ? AND file_path = ?").run(findingKind, filePath);
488
+ }
489
+
490
+ /**
491
+ * Search findings via FTS5 MATCH, ranked by bm25.
492
+ * Empty query returns all findings ordered by severity, title.
493
+ * Each result is shaped as a QueryResultRow with source:'analysis'.
494
+ */
495
+ export function searchFindings(
496
+ db: DatabaseType,
497
+ query: string,
498
+ kindFilter?: string,
499
+ limit = 10,
500
+ ): Array<{
501
+ id: number;
502
+ name: string;
503
+ kind: string;
504
+ summary: string;
505
+ score: number;
506
+ source: "analysis";
507
+ finding_kind: string;
508
+ severity: string | null;
509
+ metric: string | null;
510
+ file_path: string;
511
+ line_start: number;
512
+ line_end: number;
513
+ }> {
514
+ if (!query.trim()) {
515
+ let sql = `
516
+ SELECT a.id, a.title as name, a.finding_kind as kind, a.body as summary,
517
+ a.severity, a.metric, a.file_path, a.line_start, a.line_end, 0.0 as score
518
+ FROM analysis a
519
+ `;
520
+ const params: unknown[] = [];
521
+ if (kindFilter) {
522
+ sql += " WHERE a.finding_kind = ?";
523
+ params.push(kindFilter);
524
+ }
525
+ sql += " ORDER BY CASE a.severity WHEN 'high' THEN 1 WHEN 'warn' THEN 2 WHEN 'info' THEN 3 ELSE 4 END, a.title LIMIT ?";
526
+ params.push(limit);
527
+ const rows = db.prepare(sql).all(...params) as any[];
528
+ return rows.map((r) => ({
529
+ ...r,
530
+ source: "analysis" as const,
531
+ finding_kind: r.kind as string,
532
+ file_path: r.file_path ?? "",
533
+ line_start: r.line_start ?? 0,
534
+ line_end: r.line_end ?? 0,
535
+ severity: r.severity ?? null,
536
+ metric: r.metric ?? null,
537
+ }));
538
+ }
539
+
540
+ let sql = `
541
+ SELECT a.id, a.title as name, a.finding_kind as kind, a.body as summary,
542
+ a.severity, a.metric, a.file_path, a.line_start, a.line_end,
543
+ bm25(analysis_fts, 10.0, 5.0, 3.0) as score
544
+ FROM analysis_fts
545
+ JOIN analysis a ON a.id = analysis_fts.rowid
546
+ WHERE analysis_fts MATCH ?
547
+ `;
548
+ const params: unknown[] = [query];
549
+
550
+ if (kindFilter) {
551
+ sql += " AND a.finding_kind = ?";
552
+ params.push(kindFilter);
553
+ }
554
+
555
+ sql += " ORDER BY CASE a.severity WHEN 'high' THEN 1 WHEN 'warn' THEN 2 WHEN 'info' THEN 3 ELSE 4 END, score LIMIT ?";
556
+ params.push(limit);
557
+
558
+ const rows = db.prepare(sql).all(...params) as any[];
559
+ return rows.map((r) => ({
560
+ ...r,
561
+ source: "analysis" as const,
562
+ finding_kind: r.kind as string,
563
+ file_path: r.file_path ?? "",
564
+ line_start: r.line_start ?? 0,
565
+ line_end: r.line_end ?? 0,
566
+ severity: r.severity ?? null,
567
+ metric: r.metric ?? null,
568
+ }));
569
+ }
570
+
381
571
  // ── Enrichment helpers ──────────────────────────────────────────
382
572
 
383
573
  /**
@@ -166,6 +166,103 @@ describe('formatCompact with note rows', () => {
166
166
  });
167
167
  });
168
168
 
169
+ describe('formatCompact with analysis rows', () => {
170
+ it('renders a coverage finding row with [coverage] prefix and severity', () => {
171
+ const rows: QueryResultRow[] = [{
172
+ id: 1, name: 'Low coverage: src/auth/token.ts', kind: 'coverage',
173
+ file_path: 'src/auth/token.ts', line_start: 0, line_end: 0,
174
+ signature: '', summary: 'Token path is under-tested.',
175
+ score: 0.5, source: 'analysis', finding_kind: 'coverage',
176
+ severity: 'warn', metric: '38% (24/63 lines)',
177
+ }];
178
+ const result = formatCompact(rows, null);
179
+ expect(result).toContain('Low coverage: src/auth/token.ts');
180
+ expect(result).toContain('[coverage]');
181
+ expect(result).toContain('warn');
182
+ expect(result).toContain('38%');
183
+ expect(result).toContain('token.ts');
184
+ });
185
+
186
+ it('renders a debt finding row with [debt] prefix', () => {
187
+ const rows: QueryResultRow[] = [{
188
+ id: 1, name: 'TODO: fix this', kind: 'debt',
189
+ file_path: 'src/a.ts', line_start: 5, line_end: 5,
190
+ signature: '', summary: 'Need to handle edge case',
191
+ score: 0.5, source: 'analysis', finding_kind: 'debt',
192
+ severity: 'info', metric: 'TODO',
193
+ }];
194
+ const result = formatCompact(rows, null);
195
+ expect(result).toContain('[debt]');
196
+ expect(result).toContain('TODO: fix this');
197
+ expect(result).toContain('info');
198
+ });
199
+
200
+ it('renders a practice finding row with [practice] prefix', () => {
201
+ const rows: QueryResultRow[] = [{
202
+ id: 1, name: 'Missing error handling', kind: 'practice',
203
+ file_path: 'src/api/route.ts', line_start: 15, line_end: 15,
204
+ signature: '', summary: 'No error handling in this function.',
205
+ score: 0.5, source: 'analysis', finding_kind: 'practice',
206
+ severity: 'high', metric: '',
207
+ }];
208
+ const result = formatCompact(rows, null);
209
+ expect(result).toContain('[practice]');
210
+ expect(result).toContain('high');
211
+ });
212
+
213
+ it('renders mixed code + analysis rows', () => {
214
+ const codeRow: QueryResultRow = {
215
+ id: 1, name: 'myFunc', kind: 'function',
216
+ file_path: 'src/util/helper.ts', line_start: 10, line_end: 20,
217
+ signature: '', summary: 'Does something', score: 0.5,
218
+ };
219
+ const analysisRow: QueryResultRow = {
220
+ id: 2, name: 'Low coverage', kind: 'coverage',
221
+ file_path: 'src/util/helper.ts', line_start: 0, line_end: 0,
222
+ signature: '', summary: 'Low coverage.',
223
+ score: 0.3, source: 'analysis', finding_kind: 'coverage',
224
+ severity: 'warn', metric: '30%',
225
+ };
226
+ const result = formatCompact([codeRow, analysisRow], null);
227
+ expect(result).toContain('helper.ts:10-20');
228
+ expect(result).toContain('[coverage]');
229
+ expect(result.split('\n')).toHaveLength(2);
230
+ });
231
+
232
+ it('renders mixed code + lib + note + analysis rows', () => {
233
+ const codeRow: QueryResultRow = {
234
+ id: 1, name: 'myFunc', kind: 'function',
235
+ file_path: 'src/util/helper.ts', line_start: 10, line_end: 20,
236
+ signature: '', summary: 'Does something', score: 0.5,
237
+ };
238
+ const libRow: QueryResultRow = {
239
+ id: 100, name: 'createMiddleware', kind: 'function',
240
+ file_path: 'hono/dist/helper.d.ts', line_start: 0, line_end: 0,
241
+ signature: '', summary: 'Define a typed middleware handler.',
242
+ score: 0.3, source: 'lib', lib: 'hono', version: '4.6.3',
243
+ };
244
+ const noteRow: QueryResultRow = {
245
+ id: 1, name: 'Retry Key', kind: 'glossary',
246
+ file_path: '', line_start: 0, line_end: 0,
247
+ signature: '', summary: 'Key for idempotent retries.',
248
+ score: 0.5, source: 'note', note_kind: 'glossary', tags: '',
249
+ };
250
+ const analysisRow: QueryResultRow = {
251
+ id: 2, name: 'Low coverage', kind: 'coverage',
252
+ file_path: 'src/util/helper.ts', line_start: 0, line_end: 0,
253
+ signature: '', summary: 'Low coverage.',
254
+ score: 0.3, source: 'analysis', finding_kind: 'coverage',
255
+ severity: 'warn', metric: '30%',
256
+ };
257
+
258
+ const result = formatCompact([codeRow, libRow, noteRow, analysisRow], null);
259
+ expect(result).toContain('[coverage]');
260
+ expect(result).toContain('[hono@4.6.3]');
261
+ expect(result).toContain('[glossary]');
262
+ expect(result.split('\n')).toHaveLength(4);
263
+ });
264
+ });
265
+
169
266
  describe('formatCardBody', () => {
170
267
  it('returns the card body text', () => {
171
268
  const body = '# myFunc\n\nDoes something.\n';
package/src/format.ts CHANGED
@@ -32,6 +32,14 @@ export function formatCompact(
32
32
  const summary = truncate(row.summary || "", SUMMARY_MAX);
33
33
  return `${row.name} · ${row.note_kind} · ${prefix} · ${summary}`;
34
34
  }
35
+ if (row.source === "analysis") {
36
+ const kindTag = `[${row.finding_kind || "finding"}]`;
37
+ const sev = row.severity ? ` · ${row.severity}` : "";
38
+ const metric = row.metric ? ` · ${row.metric}` : "";
39
+ const loc = row.file_path ? ` · ${basename(row.file_path)}` : "";
40
+ const summary = truncate(row.summary || row.name || "", SUMMARY_MAX);
41
+ return `${row.name}${loc}${sev}${metric} · ${kindTag} · ${summary}`;
42
+ }
35
43
  if (row.source === "lib" && row.lib && row.version) {
36
44
  const origin = `[${row.lib}@${row.version}]`;
37
45
  const summary = truncate(row.summary || "", SUMMARY_MAX);
@@ -78,6 +78,15 @@ describe('index.ts contract', () => {
78
78
  expect(noteParams.properties).toHaveProperty('title');
79
79
  expect(noteParams.properties).toHaveProperty('body');
80
80
 
81
+ // Check v1.4 tools
82
+ const findingTool = stub.tools.find(t => t.name === 'codewalker_finding');
83
+ expect(findingTool).toBeDefined();
84
+ expect(findingTool!.description).toContain('finding');
85
+ const findingParams = (findingTool!.parameters as any);
86
+ expect(findingParams.properties).toHaveProperty('kind');
87
+ expect(findingParams.properties).toHaveProperty('title');
88
+ expect(findingParams.properties).toHaveProperty('body');
89
+
81
90
  // Check tool has a source parameter
82
91
  const toolParams = (queryTool!.parameters as any);
83
92
  expect(toolParams.properties).toHaveProperty('source');
@@ -173,4 +182,26 @@ describe('index.ts contract', () => {
173
182
  expect(cmd.description).toContain('glossary');
174
183
  expect(cmd.description).toContain('decisions');
175
184
  });
185
+
186
+ it('command description includes v1.4 subcommands (analyze, review, findings, conventions)', async () => {
187
+ const mod = await import('./index.ts');
188
+ const stub = createPiStub();
189
+ mod.default(stub.api as any);
190
+
191
+ const cmd = stub.commands.find(c => c.name === 'codewalker')!;
192
+ expect(cmd.description).toContain('analyze');
193
+ expect(cmd.description).toContain('review');
194
+ expect(cmd.description).toContain('findings');
195
+ expect(cmd.description).toContain('conventions');
196
+ });
197
+
198
+ it('codewalker_note tool accepts type="convention"', async () => {
199
+ const mod = await import('./index.ts');
200
+ const stub = createPiStub();
201
+ mod.default(stub.api as any);
202
+
203
+ const noteTool = stub.tools.find(t => t.name === 'codewalker_note')!;
204
+ const noteParams = (noteTool!.parameters as any);
205
+ expect(noteParams.properties.type.description).toContain('convention');
206
+ });
176
207
  });