@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/index.ts CHANGED
@@ -17,10 +17,13 @@ import { runQuery } from "./query.ts";
17
17
  import { scan, sync } from "./indexer.ts";
18
18
  import { indexLibraries } from "./libs/indexer.ts";
19
19
  import { formatCompact } from "./format.ts";
20
- import { openDb, selectUnenrichedSymbols, updateSymbolSummary, searchNotes, upsertNote } from "./db.ts";
20
+ import { openDb, selectUnenrichedSymbols, updateSymbolSummary, searchNotes, upsertNote, upsertFinding, searchFindings, deleteFindingsForFile } from "./db.ts";
21
21
  import { updateCardSummary } from "./cards.ts";
22
22
  import { addNote } from "./notes.ts";
23
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";
24
27
  import type { NoteKind } from "./types.ts";
25
28
 
26
29
  export default function codewalkerExtension(pi: ExtensionAPI): void {
@@ -33,7 +36,8 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
33
36
  description:
34
37
  "Search the project's code index for symbols (functions, consts, classes, types). " +
35
38
  "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').",
39
+ "Optionally search libraries (source='libs') or notes/glossary/decisions (source='notes') or " +
40
+ "analysis findings (source='analysis') or all sources (source='all').",
37
41
  parameters: Type.Object({
38
42
  query: Type.String({ description: "Search text — symbol name or concept keywords." }),
39
43
  kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum|glossary|decision" })),
@@ -126,18 +130,18 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
126
130
  },
127
131
  });
128
132
 
129
- // -- codewalker_note (v1.3 — write a glossary term or decision)
133
+ // -- codewalker_note (v1.3 — write a glossary term, decision, or convention)
130
134
  pi.registerTool({
131
135
  name: "codewalker_note",
132
136
  label: "Codewalker Note",
133
137
  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 " +
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 " +
136
140
  "will surface this conceptual knowledge alongside code symbols.",
137
141
  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." }),
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." }),
141
145
  tags: Type.Optional(Type.String({ description: "Comma-separated tags." })),
142
146
  related: Type.Optional(Type.String({ description: "Comma-separated symbol names or file:line refs." })),
143
147
  }),
@@ -145,15 +149,18 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
145
149
  const p = params as any;
146
150
  const type = (p.type as string).toLowerCase();
147
151
 
148
- if (type !== "glossary" && type !== "decision") {
152
+ if (type !== "glossary" && type !== "decision" && type !== "convention") {
149
153
  return {
150
- content: [{ type: "text" as const, text: `Invalid note type "${type}". Must be "glossary" or "decision".` }],
154
+ content: [{ type: "text" as const, text: `Invalid note type "${type}". Must be "glossary", "decision", or "convention".` }],
151
155
  details: { error: "invalid_type" },
152
156
  };
153
157
  }
154
158
 
155
159
  const project = await ensureProject(ctx.cwd);
156
- const notesDir = type === "glossary" ? project.glossaryDir : project.decisionsDir;
160
+ let notesDir: string;
161
+ if (type === "glossary") notesDir = project.glossaryDir;
162
+ else if (type === "decision") notesDir = project.decisionsDir;
163
+ else notesDir = project.conventionsDir;
157
164
 
158
165
  addNote(project.dbPath, {
159
166
  note_kind: type as NoteKind,
@@ -164,21 +171,120 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
164
171
  card_path: "",
165
172
  }, notesDir);
166
173
 
174
+ const kindLabel = type === "glossary" ? "Glossary term" : type === "decision" ? "Decision" : "Convention";
167
175
  return {
168
- content: [{ type: "text" as const, text: `${type === "glossary" ? "Glossary term" : "Decision"} "${p.title}" saved and indexed.` }],
176
+ content: [{ type: "text" as const, text: `${kindLabel} "${p.title}" saved and indexed.` }],
169
177
  details: { type, title: p.title },
170
178
  };
171
179
  },
172
180
  });
173
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
+
174
276
  // ----------------------------------------------------------------- command
175
277
  pi.registerCommand("codewalker", {
176
278
  description:
177
- "codewalker: scan | sync | query <text> | enrich <path> [--max=N] | glossary [query] | decisions [query] | 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" +
178
280
  " scan Full (re)build of the code index\n" +
179
281
  " sync Git-anchored incremental update\n" +
180
282
  " query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
181
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" +
182
288
  " glossary [query] Search glossary terms\n" +
183
289
  " decisions [query] Search decision notes\n" +
184
290
  " libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
@@ -355,13 +461,120 @@ export default function codewalkerExtension(pi: ExtensionAPI): void {
355
461
  break;
356
462
  }
357
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
+
358
567
  default: {
359
568
  notify(
360
- "codewalker: scan | sync | query <text> | enrich <path> [--max=N] | glossary [query] | decisions [query] | 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" +
361
570
  " scan Full (re)build of the code index\n" +
362
571
  " sync Git-anchored incremental update\n" +
363
572
  " query <text> Search the code index (pass source=notes to include glossary/decisions)\n" +
364
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" +
365
578
  " glossary [query] Search glossary terms\n" +
366
579
  " decisions [query] Search decision notes\n" +
367
580
  " libs [--dev] Index all direct dependencies (--dev includes devDependencies)\n" +
@@ -73,7 +73,7 @@ export function parseNoteCard(text: string): {
73
73
  }
74
74
 
75
75
  const noteKind = fm["note_kind"];
76
- if (noteKind !== "glossary" && noteKind !== "decision") return null;
76
+ if (noteKind !== "glossary" && noteKind !== "decision" && noteKind !== "convention") return null;
77
77
  if (!fm["title"]) return null;
78
78
 
79
79
  return {
package/src/notes.ts CHANGED
@@ -64,6 +64,7 @@ export function rebuildNotesDbFromCards(
64
64
  dbPath: string,
65
65
  glossaryDir: string,
66
66
  decisionsDir: string,
67
+ conventionsDir?: string,
67
68
  ): void {
68
69
  const db = openDb(dbPath);
69
70
 
@@ -83,6 +84,11 @@ export function rebuildNotesDbFromCards(
83
84
  processCardsInDir(db, decisionsDir, "decision");
84
85
  }
85
86
 
87
+ // Process conventions cards (v1.4)
88
+ if (conventionsDir && fs.existsSync(conventionsDir)) {
89
+ processCardsInDir(db, conventionsDir, "convention");
90
+ }
91
+
86
92
  db.exec("COMMIT");
87
93
  } catch (e) {
88
94
  db.exec("ROLLBACK");
@@ -89,6 +89,13 @@ describe('project.ts', () => {
89
89
  expect(p.glossaryDir).toBe(path.join(p.codewalkerDir, 'entries', 'glossary'));
90
90
  expect(p.decisionsDir).toBe(path.join(p.codewalkerDir, 'entries', 'decisions'));
91
91
  });
92
+
93
+ it('exposes analysisDir and conventionsDir paths', async () => {
94
+ const mod = await import('./project.ts');
95
+ const p = mod.resolveProject(tmpDir);
96
+ expect(p.analysisDir).toBe(path.join(p.codewalkerDir, 'entries', 'analysis'));
97
+ expect(p.conventionsDir).toBe(path.join(p.codewalkerDir, 'entries', 'conventions'));
98
+ });
92
99
  });
93
100
 
94
101
  describe('ensureProject', () => {
@@ -104,6 +111,8 @@ describe('project.ts', () => {
104
111
  expect(fs.existsSync(p.libsDir)).toBe(true);
105
112
  expect(fs.existsSync(p.glossaryDir)).toBe(true);
106
113
  expect(fs.existsSync(p.decisionsDir)).toBe(true);
114
+ expect(fs.existsSync(p.analysisDir)).toBe(true);
115
+ expect(fs.existsSync(p.conventionsDir)).toBe(true);
107
116
  // meta.json written
108
117
  expect(fs.existsSync(p.metaFile)).toBe(true);
109
118
  // meta.json has correct shape
package/src/project.ts CHANGED
@@ -33,6 +33,8 @@ export interface ProjectPaths {
33
33
  libsDir: string;
34
34
  glossaryDir: string;
35
35
  decisionsDir: string;
36
+ analysisDir: string;
37
+ conventionsDir: string;
36
38
  }
37
39
 
38
40
  function piHome(): string {
@@ -129,6 +131,8 @@ function pathsForId(id: string, root: string, configDir: string, markerPath: str
129
131
  libsDir: path.join(globalDir, "codewalker", "entries", "libs"),
130
132
  glossaryDir: path.join(globalDir, "codewalker", "entries", "glossary"),
131
133
  decisionsDir: path.join(globalDir, "codewalker", "entries", "decisions"),
134
+ analysisDir: path.join(globalDir, "codewalker", "entries", "analysis"),
135
+ conventionsDir: path.join(globalDir, "codewalker", "entries", "conventions"),
132
136
  };
133
137
  }
134
138
 
@@ -170,7 +174,7 @@ export async function ensureProject(cwd: string): Promise<ProjectPaths> {
170
174
  const p = resolveProject(cwd);
171
175
  const nowISO = new Date().toISOString();
172
176
 
173
- for (const dir of [p.codewalkerDir, p.entriesDir, p.symbolsDir, p.libsDir, p.glossaryDir, p.decisionsDir]) {
177
+ for (const dir of [p.codewalkerDir, p.entriesDir, p.symbolsDir, p.libsDir, p.glossaryDir, p.decisionsDir, p.analysisDir, p.conventionsDir]) {
174
178
  fs.mkdirSync(dir, { recursive: true });
175
179
  }
176
180
 
package/src/query.test.ts CHANGED
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import * as os from 'node:os';
5
- import { openDb, upsertSymbol, setMeta, getMeta, upsertLibSymbol, upsertNote, searchLibSymbols } from './db.ts';
5
+ import { openDb, upsertSymbol, setMeta, getMeta, upsertLibSymbol, upsertNote, upsertFinding, searchLibSymbols } from './db.ts';
6
6
  import { runQuery } from './query.ts';
7
7
 
8
8
  describe('query.ts', () => {
@@ -242,4 +242,79 @@ describe('query.ts', () => {
242
242
  expect(sources).toContain('note');
243
243
  });
244
244
 
245
+ // ── analysis source ────────────────────────────────────────
246
+ it('source="analysis" returns only findings', () => {
247
+ const db = openDb(dbPath);
248
+ upsertFinding(db, {
249
+ finding_kind: 'debt', title: 'TODO: fix', severity: 'info',
250
+ file_path: 'src/a.ts', line_start: 1, line_end: 1,
251
+ metric: 'TODO', body: 'Fix this', related: '', card_path: '',
252
+ });
253
+ upsertSymbol(db, {
254
+ name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
255
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
256
+ });
257
+ db.close();
258
+
259
+ const result = runQuery(dbPath, { query: '', source: 'analysis' });
260
+ expect(result.rows).toHaveLength(1);
261
+ expect(result.rows[0]!.source).toBe('analysis');
262
+ expect(result.rows[0]!.finding_kind).toBe('debt');
263
+ });
264
+
265
+ it('source="all" includes analysis findings interleaved with code, libs, notes', () => {
266
+ const db = openDb(dbPath);
267
+ upsertSymbol(db, {
268
+ name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
269
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
270
+ });
271
+ upsertFinding(db, {
272
+ finding_kind: 'coverage', title: 'Low coverage: a.ts', severity: 'warn',
273
+ file_path: 'src/a.ts', line_start: 0, line_end: 0,
274
+ metric: '50%', body: 'Half covered', related: '', card_path: '',
275
+ });
276
+ db.close();
277
+
278
+ const result = runQuery(dbPath, { query: '', source: 'all' });
279
+ expect(result.rows.length).toBeGreaterThanOrEqual(2);
280
+ const sources = result.rows.map(r => r.source);
281
+ expect(sources).toContain('analysis');
282
+ });
283
+
284
+ it('source="analysis" with kind filter narrows by finding_kind', () => {
285
+ const db = openDb(dbPath);
286
+ upsertFinding(db, {
287
+ finding_kind: 'coverage', title: 'Coverage gap', severity: 'warn',
288
+ file_path: 'src/a.ts', line_start: 0, line_end: 0,
289
+ metric: '50%', body: '', related: '', card_path: '',
290
+ });
291
+ upsertFinding(db, {
292
+ finding_kind: 'debt', title: 'Debt item', severity: 'high',
293
+ file_path: 'src/b.ts', line_start: 1, line_end: 1,
294
+ metric: 'TODO', body: '', related: '', card_path: '',
295
+ });
296
+ db.close();
297
+
298
+ const result = runQuery(dbPath, { query: '', source: 'analysis', kind: 'coverage' });
299
+ expect(result.rows).toHaveLength(1);
300
+ expect(result.rows[0]!.name).toBe('Coverage gap');
301
+ });
302
+
303
+ it('source="all" with analysis data respects limit', () => {
304
+ const db = openDb(dbPath);
305
+ upsertSymbol(db, {
306
+ name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
307
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
308
+ });
309
+ upsertFinding(db, {
310
+ finding_kind: 'debt', title: 'Debt', severity: 'info',
311
+ file_path: 'src/a.ts', line_start: 1, line_end: 1,
312
+ metric: 'TODO', body: '', related: '', card_path: '',
313
+ });
314
+ db.close();
315
+
316
+ const result = runQuery(dbPath, { query: '', source: 'all', limit: 1 });
317
+ expect(result.rows).toHaveLength(1);
318
+ });
319
+
245
320
  });
package/src/query.ts CHANGED
@@ -2,16 +2,16 @@
2
2
  * Query orchestration: wraps DB search with staleness detection.
3
3
  */
4
4
 
5
- import type { QueryResult, QueryResultRow, StalenessInfo, NoteKind } from "./types.ts";
6
- import { openDb, searchSymbols, searchLibSymbols, searchNotes, getMeta } from "./db.ts";
5
+ import type { QueryResult, QueryResultRow, StalenessInfo, NoteKind, FindingKind } from "./types.ts";
6
+ import { openDb, searchSymbols, searchLibSymbols, searchNotes, searchFindings, getMeta } from "./db.ts";
7
7
  import { getHeadSha, changedFilesSince } from "./git.ts";
8
8
 
9
9
  export interface QueryParams {
10
10
  query: string;
11
11
  kind?: string;
12
12
  limit?: number;
13
- /** Source scope: "code" (default, only code symbols), "libs" (only lib symbols), "notes" (only notes), or "all" (all three). */
14
- source?: "code" | "libs" | "notes" | "all";
13
+ /** Source scope: "code" (default, only code symbols), "libs" (only lib symbols), "notes" (only notes), "analysis" (only findings), or "all" (all four). */
14
+ source?: "code" | "libs" | "notes" | "analysis" | "all";
15
15
  }
16
16
 
17
17
  /**
@@ -39,14 +39,19 @@ export function runQuery(
39
39
  } else if (source === "notes") {
40
40
  const noteRows = searchNotes(db, params.query, params.kind as NoteKind | undefined, limit);
41
41
  rows = noteRows as unknown as QueryResultRow[];
42
+ } else if (source === "analysis") {
43
+ const kindFilter = params.kind && ["coverage", "debt", "practice"].includes(params.kind) ? params.kind : undefined;
44
+ const analysisRows = searchFindings(db, params.query, kindFilter, limit) as unknown as QueryResultRow[];
45
+ rows = analysisRows as unknown as QueryResultRow[];
42
46
  } else if (source === "all") {
43
- // Run code + lib + note searches, merge, sort by score, apply limit
47
+ // Run code + lib + note + analysis searches, merge, sort by score, apply limit
44
48
  const codeRows = searchSymbols(db, params.query, params.kind, limit);
45
49
  const libRows = searchLibSymbols(db, params.query, params.kind, limit) as unknown as QueryResultRow[];
46
50
  const noteRows = searchNotes(db, params.query, params.kind as NoteKind | undefined, limit * 2) as unknown as QueryResultRow[];
51
+ const analysisRows = searchFindings(db, params.query, undefined, limit * 2) as unknown as QueryResultRow[];
47
52
 
48
53
  // Merge and sort by score ascending (lower bm25 = better match)
49
- const merged: QueryResultRow[] = [...codeRows, ...libRows, ...noteRows];
54
+ const merged: QueryResultRow[] = [...codeRows, ...libRows, ...noteRows, ...analysisRows];
50
55
  merged.sort((a, b) => a.score - b.score);
51
56
  rows = merged.slice(0, limit);
52
57
  } else {
package/src/types.ts CHANGED
@@ -65,7 +65,7 @@ export interface CardHead {
65
65
  }
66
66
 
67
67
  /** Note kind discriminator for bridge cards. */
68
- export type NoteKind = "glossary" | "decision";
68
+ export type NoteKind = "glossary" | "decision" | "convention";
69
69
 
70
70
  /** A glossary/decision note (bridge card) for conceptual knowledge. */
71
71
  export interface Note {
@@ -77,6 +77,31 @@ export interface Note {
77
77
  card_path: string;
78
78
  }
79
79
 
80
+ /** Analysis finding kind discriminator. */
81
+ export type FindingKind = "coverage" | "debt" | "practice";
82
+
83
+ /** A single analysis finding (coverage gap, technical debt, or best-practice finding). */
84
+ export interface Finding {
85
+ finding_kind: FindingKind;
86
+ title: string;
87
+ severity?: "info" | "warn" | "high";
88
+ file_path?: string;
89
+ line_start?: number;
90
+ line_end?: number;
91
+ metric?: string;
92
+ body?: string;
93
+ related?: string;
94
+ card_path?: string;
95
+ }
96
+
97
+ /** Per-file coverage data from lcov or coverage-final.json. */
98
+ export interface FileCoverage {
99
+ file: string;
100
+ lines_total: number;
101
+ lines_covered: number;
102
+ pct: number;
103
+ }
104
+
80
105
  /** A single row returned from a query. */
81
106
  export interface QueryResultRow {
82
107
  name: string;
@@ -88,8 +113,11 @@ export interface QueryResultRow {
88
113
  summary: string;
89
114
  score: number;
90
115
  id: number;
91
- /** Origin fields — code rows omit these; lib / note rows set them. */
92
- source?: "code" | "lib" | "note";
116
+ /** Origin fields — code rows omit these; lib / note / analysis rows set them. */
117
+ source?: "code" | "lib" | "note" | "analysis";
118
+ finding_kind?: FindingKind;
119
+ severity?: string;
120
+ metric?: string;
93
121
  lib?: string;
94
122
  version?: string;
95
123
  /** Note-specific fields — only for source === "note" rows. */