@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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Technical debt scanner for codewalker v1.4.
3
+ *
4
+ * PURE module — no I/O. Scans source file content for:
5
+ * - TODO/FIXME/HACK/XXX markers
6
+ * - @ts-ignore / @ts-nocheck usage
7
+ * - Oversized files (>400 lines)
8
+ * - Long functions (>120 lines, derived from existing symbol rows)
9
+ */
10
+
11
+ /** Default threshold for oversized file warning (lines). */
12
+ export const LARGE_FILE_LINES = 400;
13
+
14
+ /** Default threshold for long function warning (lines). */
15
+ export const LONG_FN_LINES = 120;
16
+
17
+ /** A single debt finding. */
18
+ export interface DebtFinding {
19
+ title: string;
20
+ file_path: string;
21
+ line_start: number;
22
+ line_end: number;
23
+ marker: string;
24
+ severity: "info" | "warn" | "high";
25
+ metric: string;
26
+ body: string;
27
+ }
28
+
29
+ /** A symbol row passed for function-length analysis. */
30
+ interface SymbolSpan {
31
+ name: string;
32
+ kind: string;
33
+ file_path: string;
34
+ line_start: number;
35
+ line_end: number;
36
+ }
37
+
38
+ // Word-boundary regex for each marker type.
39
+ const MARKER_RE = /\b(TODO|FIXME|HACK|XXX)\b(?:\s*[:-]?\s*(.*))?/g;
40
+ const TS_IGNORE_RE = /\/\/\s*@ts-ignore\b/g;
41
+ const TS_NOCHECK_RE = /\/\/\s*@ts-nocheck\b/g;
42
+
43
+ /**
44
+ * Scan a file's content for debt markers and heuristics.
45
+ *
46
+ * @param filePath - Absolute or relative file path (for the finding).
47
+ * @param content - The full file content string.
48
+ * @param symbols - Existing symbol rows for this file (for function-length analysis).
49
+ * @returns An array of debt findings.
50
+ */
51
+ export function scanDebt(
52
+ filePath: string,
53
+ content: string,
54
+ symbols: SymbolSpan[],
55
+ ): DebtFinding[] {
56
+ const findings: DebtFinding[] = [];
57
+ const lines = content ? content.split("\n") : [];
58
+
59
+ // --- Scan markers in content ---
60
+ for (let i = 0; i < lines.length; i++) {
61
+ const line = lines[i] ?? "";
62
+
63
+ // TODO/FIXME/HACK/XXX
64
+ MARKER_RE.lastIndex = 0;
65
+ let m: RegExpExecArray | null;
66
+ while ((m = MARKER_RE.exec(line)) !== null) {
67
+ const marker = m[1]!;
68
+ const detail = (m[2] ?? "").trim();
69
+ const severity = marker === "FIXME" || marker === "HACK" ? "warn" : "info";
70
+ findings.push({
71
+ title: `${marker}: ${detail || line.trim().slice(0, 50)}`,
72
+ file_path: filePath,
73
+ line_start: i + 1,
74
+ line_end: i + 1,
75
+ marker,
76
+ severity,
77
+ metric: marker,
78
+ body: line.trim(),
79
+ });
80
+ }
81
+
82
+ // @ts-ignore
83
+ TS_IGNORE_RE.lastIndex = 0;
84
+ if (TS_IGNORE_RE.test(line)) {
85
+ findings.push({
86
+ title: `@ts-ignore at line ${i + 1}`,
87
+ file_path: filePath,
88
+ line_start: i + 1,
89
+ line_end: i + 1,
90
+ marker: "@ts-ignore",
91
+ severity: "warn",
92
+ metric: "@ts-ignore",
93
+ body: line.trim(),
94
+ });
95
+ }
96
+
97
+ // @ts-nocheck
98
+ TS_NOCHECK_RE.lastIndex = 0;
99
+ if (TS_NOCHECK_RE.test(line)) {
100
+ findings.push({
101
+ title: `@ts-nocheck in ${filePath}`,
102
+ file_path: filePath,
103
+ line_start: i + 1,
104
+ line_end: i + 1,
105
+ marker: "@ts-nocheck",
106
+ severity: "high",
107
+ metric: "@ts-nocheck",
108
+ body: line.trim(),
109
+ });
110
+ }
111
+ }
112
+
113
+ // --- Oversized file heuristic ---
114
+ if (lines.length > LARGE_FILE_LINES) {
115
+ findings.push({
116
+ title: `Oversized file: ${lines.length} lines`,
117
+ file_path: filePath,
118
+ line_start: 0,
119
+ line_end: lines.length,
120
+ marker: "oversized-file",
121
+ severity: "warn",
122
+ metric: `${lines.length} lines`,
123
+ body: `File has ${lines.length} lines, exceeding the ${LARGE_FILE_LINES}-line threshold. Consider splitting into smaller modules.`,
124
+ });
125
+ }
126
+
127
+ // --- Long function heuristic (from existing symbols) ---
128
+ for (const sym of symbols) {
129
+ if (sym.file_path !== filePath) continue;
130
+ const fnLength = sym.line_end - sym.line_start;
131
+ if (fnLength > LONG_FN_LINES) {
132
+ findings.push({
133
+ title: `Long function: ${sym.name} (${fnLength} lines)`,
134
+ file_path: filePath,
135
+ line_start: sym.line_start,
136
+ line_end: sym.line_end,
137
+ marker: "long-function",
138
+ severity: "warn",
139
+ metric: `fn length ${fnLength}`,
140
+ body: `Function "${sym.name}" spans ${fnLength} lines (limit: ${LONG_FN_LINES}). Consider refactoring.`,
141
+ });
142
+ }
143
+ }
144
+
145
+ return findings;
146
+ }
147
+
148
+ /**
149
+ * Group and summarize debt findings for a file.
150
+ * Returns at most one finding per marker type with aggregated counts.
151
+ */
152
+ export function summarizeDebt(findings: DebtFinding[]): DebtFinding[] {
153
+ if (findings.length === 0) return [];
154
+
155
+ const groups = new Map<string, DebtFinding[]>();
156
+ for (const f of findings) {
157
+ const key = f.marker;
158
+ if (!groups.has(key)) groups.set(key, []);
159
+ groups.get(key)!.push(f);
160
+ }
161
+
162
+ const result: DebtFinding[] = [];
163
+ for (const [, group] of groups) {
164
+ const first = group[0]!;
165
+ if (group.length === 1) {
166
+ result.push(first);
167
+ } else {
168
+ result.push({
169
+ ...first,
170
+ title: `${first.marker}: ${group.length} occurrences`,
171
+ metric: `${first.marker} x${group.length}`,
172
+ body: group.map(f => ` line ${f.line_start}: ${f.body}`).join("\n"),
173
+ line_start: group[0]!.line_start,
174
+ line_end: group[group.length - 1]!.line_end,
175
+ });
176
+ }
177
+ }
178
+
179
+ return result;
180
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ validateReviewPath,
4
+ checkReviewCap,
5
+ selectFilesForReview,
6
+ formatReviewWorklist,
7
+ DEFAULT_REVIEW_CAP,
8
+ } from './review.ts';
9
+
10
+ describe('validateReviewPath', () => {
11
+ it('accepts a non-empty path', () => {
12
+ const result = validateReviewPath('src/auth');
13
+ expect(result.valid).toBe(true);
14
+ expect(result.error).toBeUndefined();
15
+ });
16
+
17
+ it('rejects empty string', () => {
18
+ const result = validateReviewPath('');
19
+ expect(result.valid).toBe(false);
20
+ expect(result.error).toContain('Specify a path');
21
+ });
22
+
23
+ it('rejects whitespace-only', () => {
24
+ const result = validateReviewPath(' ');
25
+ expect(result.valid).toBe(false);
26
+ expect(result.error).toContain('Specify a path');
27
+ });
28
+
29
+ it('rejects undefined/null', () => {
30
+ const result = validateReviewPath(undefined as any);
31
+ expect(result.valid).toBe(false);
32
+ expect(result.error).toContain('Specify a path');
33
+ });
34
+ });
35
+
36
+ describe('checkReviewCap', () => {
37
+ it('returns ok when count within cap', () => {
38
+ const result = checkReviewCap(5, 25);
39
+ expect(result.ok).toBe(true);
40
+ expect(result.count).toBe(5);
41
+ expect(result.skipped).toBe(0);
42
+ expect(result.error).toBeUndefined();
43
+ });
44
+
45
+ it('rejects when count exceeds cap', () => {
46
+ const result = checkReviewCap(100, 25);
47
+ expect(result.ok).toBe(false);
48
+ expect(result.count).toBe(100);
49
+ expect(result.skipped).toBe(75);
50
+ expect(result.error).toContain('Narrow your path');
51
+ });
52
+
53
+ it('uses default cap when not specified', () => {
54
+ const result = checkReviewCap(50);
55
+ expect(result.ok).toBe(false);
56
+ expect(result.error).toContain('--max');
57
+ });
58
+
59
+ it('accepts edge case at exactly cap', () => {
60
+ const result = checkReviewCap(25, 25);
61
+ expect(result.ok).toBe(true);
62
+ expect(result.count).toBe(25);
63
+ });
64
+ });
65
+
66
+ describe('selectFilesForReview', () => {
67
+ const files = [
68
+ 'src/auth/token.ts',
69
+ 'src/auth/session.ts',
70
+ 'src/api/route.ts',
71
+ 'src/api/handler.ts',
72
+ 'src/db/query.ts',
73
+ ];
74
+
75
+ it('selects files under a path prefix', () => {
76
+ const result = selectFilesForReview(files, 'src/auth', 25);
77
+ expect(result).toHaveLength(2);
78
+ expect(result).toContain('src/auth/token.ts');
79
+ expect(result).toContain('src/auth/session.ts');
80
+ });
81
+
82
+ it('respects the cap', () => {
83
+ const result = selectFilesForReview(files, 'src/', 3);
84
+ expect(result).toHaveLength(3);
85
+ });
86
+
87
+ it('returns empty array for non-matching prefix', () => {
88
+ const result = selectFilesForReview(files, 'src/nonexistent', 25);
89
+ expect(result).toEqual([]);
90
+ });
91
+
92
+ it('returns all matching files when under cap', () => {
93
+ const result = selectFilesForReview(files, 'src/api', 25);
94
+ expect(result).toHaveLength(2);
95
+ });
96
+
97
+ it('returns exact cap when match count equals cap', () => {
98
+ const result = selectFilesForReview(files, '', 5);
99
+ expect(result).toHaveLength(5);
100
+ });
101
+ });
102
+
103
+ describe('formatReviewWorklist', () => {
104
+ it('formats a worklist with file list and instructions', () => {
105
+ const files = ['src/auth/token.ts', 'src/auth/session.ts'];
106
+ const result = formatReviewWorklist(files, 'src/auth');
107
+ expect(result).toContain('2 file(s) under');
108
+ expect(result).toContain('src/auth');
109
+ expect(result).toContain('src/auth/token.ts');
110
+ expect(result).toContain('src/auth/session.ts');
111
+ expect(result).toContain('codewalker_finding');
112
+ expect(result).toContain('conventions');
113
+ expect(result).toContain('decisions');
114
+ });
115
+
116
+ it('handles empty file list', () => {
117
+ const result = formatReviewWorklist([], 'src/auth');
118
+ expect(result).toContain('No files found');
119
+ });
120
+
121
+ it('includes the max files info when files hit the cap', () => {
122
+ const files = Array.from({ length: 25 }, (_, i) => `src/a${i}.ts`);
123
+ const result = formatReviewWorklist(files, 'src/');
124
+ expect(result).toContain('25');
125
+ expect(result).toContain('selected for review');
126
+ });
127
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Review helpers for codewalker v1.4.
3
+ *
4
+ * Pure/lightweight helpers for the agent-driven best-practice review workflow:
5
+ * - `validateReviewPath()` — ensures path is provided
6
+ * - `checkReviewCap()` — enforces the review cap guardrail
7
+ * - `selectFilesForReview()` — selects files under a path prefix with cap
8
+ * - `formatReviewWorklist()` — formats worklist items for agent display
9
+ *
10
+ * Mirrors the shape of enrich.ts.
11
+ */
12
+
13
+ /** Default maximum files per review command. */
14
+ export const DEFAULT_REVIEW_CAP = 25;
15
+
16
+ /** Result of validateReviewPath. */
17
+ export interface PathValidation {
18
+ valid: boolean;
19
+ error?: string;
20
+ }
21
+
22
+ /** Result of checkReviewCap. */
23
+ export interface CapCheck {
24
+ ok: boolean;
25
+ count: number;
26
+ /** Number of files over the cap (only when !ok). */
27
+ skipped: number;
28
+ error?: string;
29
+ }
30
+
31
+ /**
32
+ * Validate that a review path was provided.
33
+ */
34
+ export function validateReviewPath(path: string | undefined | null): PathValidation {
35
+ const trimmed = (path ?? "").trim();
36
+ if (!trimmed) {
37
+ return {
38
+ valid: false,
39
+ error: 'Specify a path, e.g. src/auth. Bare /codewalker review with no path is not allowed.',
40
+ };
41
+ }
42
+ return { valid: true };
43
+ }
44
+
45
+ /**
46
+ * Check whether a count of files exceeds the cap.
47
+ * If ok is false, the caller should refuse and tell the user to narrow the path.
48
+ *
49
+ * @param count - Number of files found.
50
+ * @param cap - Maximum allowed (default: DEFAULT_REVIEW_CAP = 25).
51
+ */
52
+ export function checkReviewCap(count: number, cap: number = DEFAULT_REVIEW_CAP): CapCheck {
53
+ if (count > cap) {
54
+ const skipped = count - cap;
55
+ return {
56
+ ok: false,
57
+ count,
58
+ skipped,
59
+ error: `Refusing to review ${count} files (cap ${cap}). ` +
60
+ `Narrow your path or raise the cap with --max=${count}.`,
61
+ };
62
+ }
63
+ return { ok: true, count, skipped: 0 };
64
+ }
65
+
66
+ /**
67
+ * Select files under a path prefix, respecting the cap.
68
+ * Files not matching the prefix are excluded.
69
+ *
70
+ * @param files - Full list of source file paths.
71
+ * @param pathPrefix - The path prefix to filter by (empty string = all files).
72
+ * @param cap - Maximum number to select.
73
+ * @returns Array of file paths matching the prefix, up to cap.
74
+ */
75
+ export function selectFilesForReview(
76
+ files: string[],
77
+ pathPrefix: string,
78
+ cap: number,
79
+ ): string[] {
80
+ const matching = pathPrefix
81
+ ? files.filter(f => f.startsWith(pathPrefix))
82
+ : files;
83
+ return matching.slice(0, cap);
84
+ }
85
+
86
+ /**
87
+ * Format a review worklist for the agent to process.
88
+ * Each line shows one file to review. Includes instructions about grounding
89
+ * findings in the project's own conventions and decisions.
90
+ */
91
+ export function formatReviewWorklist(
92
+ files: string[],
93
+ pathPrefix: string,
94
+ ): string {
95
+ if (files.length === 0) {
96
+ return `No files found under "${pathPrefix}" to review.`;
97
+ }
98
+
99
+ const lines: string[] = [
100
+ `Found ${files.length} file(s) under "${pathPrefix}" selected for review:`,
101
+ "",
102
+ ];
103
+
104
+ for (const file of files) {
105
+ lines.push(` ${file}`);
106
+ }
107
+
108
+ lines.push(
109
+ "",
110
+ "Instructions:",
111
+ ` For each file above, read its content and judge it against the project's`,
112
+ ` conventions and decisions. Before starting, query for relevant context:`,
113
+ ` codewalker_query source:'all' query:'conventions'`,
114
+ ` codewalker_query source:'all' query:'decisions'`,
115
+ "",
116
+ ` For each issue found, call \`codewalker_finding\` with kind='practice',`,
117
+ ` an appropriate severity, and a body grounded in a specific convention or`,
118
+ ` decision. Avoid generic style nits — only flag what the project's own`,
119
+ ` conventions would flag.`,
120
+ "",
121
+ ` Example: \`codewalker_finding { kind: "practice", title: "...",`,
122
+ ` file: "src/auth/token.ts", severity: "warn",`,
123
+ ` body: "Convention X says Y, but this code does Z." }\``,
124
+ );
125
+
126
+ return lines.join("\n");
127
+ }
package/src/db.test.ts CHANGED
@@ -3,7 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import * as os from 'node:os';
5
5
  import Database from 'better-sqlite3';
6
- import { openDb, bootstrapDb, upsertSymbol, searchSymbols, getMeta, setMeta, deleteFileSymbols, upsertLibrary, upsertLibSymbol, deleteLibrary, searchLibSymbols, upsertNote, searchNotes, deleteNote, updateSymbolSummary, selectUnenrichedSymbols } from './db.ts';
6
+ import { openDb, bootstrapDb, upsertSymbol, searchSymbols, getMeta, setMeta, deleteFileSymbols, upsertLibrary, upsertLibSymbol, deleteLibrary, searchLibSymbols, upsertNote, searchNotes, deleteNote, updateSymbolSummary, selectUnenrichedSymbols, upsertFinding, deleteFindingsForFile, searchFindings } from './db.ts';
7
7
 
8
8
  describe('db.ts', () => {
9
9
  let tmpDir: string;
@@ -47,11 +47,11 @@ describe('db.ts', () => {
47
47
  // No error = idempotent
48
48
  });
49
49
 
50
- it('sets user_version to 3 (v1.3 schema)', () => {
50
+ it('sets user_version to 4 (v1.4 schema)', () => {
51
51
  const db = new Database(dbPath);
52
52
  bootstrapDb(db);
53
53
  const version = db.pragma('user_version', { simple: true }) as number;
54
- expect(version).toBe(3);
54
+ expect(version).toBe(4);
55
55
  db.close();
56
56
  });
57
57
 
@@ -89,6 +89,19 @@ describe('db.ts', () => {
89
89
 
90
90
  const ftsTables = db.prepare("SELECT name FROM sqlite_master WHERE name='notes_fts'").all() as { name: string }[];
91
91
  expect(ftsTables.length).toBe(1);
92
+
93
+ // Analysis tables exist (v1.4 additive upgrade)
94
+ expect(tables.map(t => t.name)).toContain('analysis');
95
+ const analysisFts = db.prepare("SELECT name FROM sqlite_master WHERE name='analysis_fts'").all() as { name: string }[];
96
+ expect(analysisFts.length).toBe(1);
97
+
98
+ // Analysis triggers exist
99
+ const triggers = db.prepare("SELECT name FROM sqlite_master WHERE type='trigger'").all() as { name: string }[];
100
+ const triggerNames = triggers.map(t => t.name);
101
+ expect(triggerNames).toContain('analysis_ai');
102
+ expect(triggerNames).toContain('analysis_ad');
103
+ expect(triggerNames).toContain('analysis_au');
104
+
92
105
  db.close();
93
106
  });
94
107
  });
@@ -575,6 +588,213 @@ describe('db.ts', () => {
575
588
  });
576
589
  });
577
590
 
591
+ describe('analysis CRUD + FTS via triggers', () => {
592
+ it('creates analysis + analysis_fts tables on bootstrap', () => {
593
+ const db = openDb(dbPath);
594
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
595
+ expect(tables.map(t => t.name)).toContain('analysis');
596
+
597
+ const ftsTables = db.prepare("SELECT name FROM sqlite_master WHERE name='analysis_fts'").all() as { name: string }[];
598
+ expect(ftsTables.length).toBe(1);
599
+
600
+ const triggers = db.prepare("SELECT name FROM sqlite_master WHERE type='trigger'").all() as { name: string }[];
601
+ const triggerNames = triggers.map(t => t.name);
602
+ expect(triggerNames).toContain('analysis_ai');
603
+ expect(triggerNames).toContain('analysis_ad');
604
+ expect(triggerNames).toContain('analysis_au');
605
+ db.close();
606
+ });
607
+
608
+ it('upsertFinding inserts a finding and FTS MATCH finds it', () => {
609
+ const db = openDb(dbPath);
610
+
611
+ const id = upsertFinding(db, {
612
+ finding_kind: 'coverage',
613
+ title: 'Low coverage: src/auth/token.ts',
614
+ severity: 'warn',
615
+ file_path: 'src/auth/token.ts',
616
+ line_start: 0,
617
+ line_end: 0,
618
+ metric: '38% (24/63 lines)',
619
+ body: 'Auth token refresh path is under-tested — 38% line coverage.',
620
+ related: 'refreshToken, token.ts:42-71',
621
+ card_path: '/entries/analysis/coverage/low-coverage-src-auth-token.md',
622
+ });
623
+
624
+ expect(typeof id).toBe('number');
625
+ expect(id).toBeGreaterThan(0);
626
+
627
+ // FTS search
628
+ const results = searchFindings(db, 'coverage');
629
+ expect(results).toHaveLength(1);
630
+ expect(results[0]!.name).toBe('Low coverage: src/auth/token.ts');
631
+ expect(results[0]!.finding_kind).toBe('coverage');
632
+ expect(results[0]!.severity).toBe('warn');
633
+ expect(results[0]!.source).toBe('analysis');
634
+
635
+ db.close();
636
+ });
637
+
638
+ it('upsertFinding upserts on (finding_kind, file_path, title) — update refreshes FTS via analysis_au', () => {
639
+ const db = openDb(dbPath);
640
+
641
+ upsertFinding(db, {
642
+ finding_kind: 'coverage',
643
+ title: 'Low coverage: token.ts',
644
+ severity: 'warn',
645
+ file_path: 'src/auth/token.ts',
646
+ line_start: 0, line_end: 0,
647
+ metric: '38%',
648
+ body: 'Old description',
649
+ related: '',
650
+ card_path: '/cards/coverage-token.md',
651
+ });
652
+
653
+ // Update
654
+ upsertFinding(db, {
655
+ finding_kind: 'coverage',
656
+ title: 'Low coverage: token.ts',
657
+ severity: 'high',
658
+ file_path: 'src/auth/token.ts',
659
+ line_start: 0, line_end: 0,
660
+ metric: '25%',
661
+ body: 'Worse now with uniqueTermFindABC',
662
+ related: '',
663
+ card_path: '/cards/coverage-token.md',
664
+ });
665
+
666
+ // Search for new text (proves analysis_au fired)
667
+ const results = searchFindings(db, 'uniqueTermFindABC');
668
+ expect(results).toHaveLength(1);
669
+ expect(results[0]!.metric).toBe('25%');
670
+
671
+ // No duplicate
672
+ const all = searchFindings(db, '');
673
+ expect(all).toHaveLength(1);
674
+
675
+ db.close();
676
+ });
677
+
678
+ it('deleteFindingsForFile removes findings and FTS rows for a file', () => {
679
+ const db = openDb(dbPath);
680
+
681
+ upsertFinding(db, {
682
+ finding_kind: 'debt', title: 'TODO: fix me', severity: 'info',
683
+ file_path: 'src/a.ts', line_start: 5, line_end: 5,
684
+ metric: 'TODO', body: 'FixThisUniqueXYZ123', related: '',
685
+ card_path: '/cards/debt-a.md',
686
+ });
687
+ upsertFinding(db, {
688
+ finding_kind: 'debt', title: 'HACK: workaround', severity: 'high',
689
+ file_path: 'src/b.ts', line_start: 10, line_end: 10,
690
+ metric: 'HACK', body: 'Ugly workaround', related: '',
691
+ card_path: '/cards/debt-b.md',
692
+ });
693
+
694
+ deleteFindingsForFile(db, 'debt', 'src/a.ts');
695
+
696
+ const all = searchFindings(db, '');
697
+ expect(all).toHaveLength(1);
698
+ expect(all[0]!.name).toBe('HACK: workaround');
699
+
700
+ // FTS should be clean too — no result for the deleted finding's unique text
701
+ const ftsResults = searchFindings(db, 'FixThisUniqueXYZ123');
702
+ expect(ftsResults).toHaveLength(0);
703
+
704
+ db.close();
705
+ });
706
+
707
+ it('searchFindings empty query returns all findings ordered by severity, title', () => {
708
+ const db = openDb(dbPath);
709
+
710
+ upsertFinding(db, {
711
+ finding_kind: 'debt', title: 'Alpha debt', severity: 'info',
712
+ file_path: 'src/a.ts', line_start: 0, line_end: 0,
713
+ metric: 'TODO', body: '', related: '', card_path: '',
714
+ });
715
+ upsertFinding(db, {
716
+ finding_kind: 'coverage', title: 'Zebra coverage', severity: 'high',
717
+ file_path: 'src/b.ts', line_start: 0, line_end: 0,
718
+ metric: '10%', body: '', related: '', card_path: '',
719
+ });
720
+
721
+ const results = searchFindings(db, '');
722
+ expect(results.length).toBeGreaterThanOrEqual(2);
723
+
724
+ db.close();
725
+ });
726
+
727
+ it('searchFindings with kindFilter narrows by finding_kind', () => {
728
+ const db = openDb(dbPath);
729
+
730
+ upsertFinding(db, {
731
+ finding_kind: 'coverage', title: 'Coverage gap', severity: 'warn',
732
+ file_path: 'src/a.ts', line_start: 0, line_end: 0,
733
+ metric: '50%', body: '', related: '', card_path: '',
734
+ });
735
+ upsertFinding(db, {
736
+ finding_kind: 'debt', title: 'Debt item', severity: 'high',
737
+ file_path: 'src/b.ts', line_start: 0, line_end: 0,
738
+ metric: 'TODO', body: '', related: '', card_path: '',
739
+ });
740
+
741
+ const coverageResults = searchFindings(db, '', 'coverage');
742
+ expect(coverageResults).toHaveLength(1);
743
+ expect(coverageResults[0]!.finding_kind).toBe('coverage');
744
+
745
+ db.close();
746
+ });
747
+
748
+ it('searchFindings with query returns bm25-ranked results', () => {
749
+ const db = openDb(dbPath);
750
+
751
+ upsertFinding(db, {
752
+ finding_kind: 'debt', title: 'General', severity: 'info',
753
+ file_path: 'src/a.ts', line_start: 0, line_end: 0,
754
+ metric: 'TODO', body: 'Some text about authentication token refresh', related: '', card_path: '',
755
+ });
756
+ upsertFinding(db, {
757
+ finding_kind: 'debt', title: 'Token issue', severity: 'high',
758
+ file_path: 'src/b.ts', line_start: 0, line_end: 0,
759
+ metric: 'FIXME', body: 'Token validation is weak', related: '', card_path: '',
760
+ });
761
+
762
+ const results = searchFindings(db, 'token');
763
+ expect(results).toHaveLength(2);
764
+
765
+ db.close();
766
+ });
767
+ });
768
+
769
+ describe('convention notes', () => {
770
+ it('upsertNote accepts convention note_kind and it is searchable', () => {
771
+ const db = openDb(dbPath);
772
+
773
+ const id = upsertNote(db, {
774
+ note_kind: 'convention',
775
+ title: 'Use functional components',
776
+ body: 'All React components must be pure functions, not classes.',
777
+ tags: 'react,style',
778
+ related: 'Component, renderFunction',
779
+ card_path: '/entries/conventions/use-functional-components.md',
780
+ });
781
+
782
+ expect(id).toBeGreaterThan(0);
783
+
784
+ // Search by note_kind
785
+ const results = searchNotes(db, '', 'convention');
786
+ expect(results).toHaveLength(1);
787
+ expect(results[0]!.name).toBe('Use functional components');
788
+ expect(results[0]!.note_kind).toBe('convention');
789
+
790
+ // FTS search
791
+ const ftsResults = searchNotes(db, 'functional');
792
+ expect(ftsResults).toHaveLength(1);
793
+
794
+ db.close();
795
+ });
796
+ });
797
+
578
798
  describe('meta', () => {
579
799
  it('setMeta and getMeta round-trip values', () => {
580
800
  const db = openDb(dbPath);