@aprimediet/codewalker 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderNoteCard, parseNoteCard } from './notes-cards.ts';
3
+ import type { Note } from './types.ts';
4
+
5
+ function makeNote(overrides: Partial<Note> = {}): Note {
6
+ return {
7
+ note_kind: 'glossary',
8
+ title: 'Idempotency Key',
9
+ body: 'A client-supplied key that makes a retried POST safe to replay.',
10
+ tags: 'api, payments',
11
+ related: 'createCharge, charge.ts:88-140',
12
+ card_path: '',
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe('renderNoteCard', () => {
18
+ it('renders a glossary note with frontmatter head', () => {
19
+ const note = makeNote();
20
+ const md = renderNoteCard(note);
21
+
22
+ expect(md).toContain('---');
23
+ expect(md).toContain('note_kind: glossary');
24
+ expect(md).toContain('title: Idempotency Key');
25
+ expect(md).toContain('tags: api, payments');
26
+ expect(md).toContain('related: createCharge, charge.ts:88-140');
27
+ expect(md).toContain('summary: A client-supplied key that makes a retried POST safe to replay.');
28
+
29
+ // Body
30
+ expect(md).toContain('# Idempotency Key');
31
+ expect(md).toContain(note.body);
32
+ });
33
+
34
+ it('renders a decision note', () => {
35
+ const note = makeNote({
36
+ note_kind: 'decision',
37
+ title: 'Use SQLite over ChromaDB',
38
+ body: 'We chose SQLite+FTS5 because the agent can expand queries itself.',
39
+ tags: 'tech-decision, database',
40
+ related: 'docs/tech-decision.md',
41
+ });
42
+ const md = renderNoteCard(note);
43
+ expect(md).toContain('note_kind: decision');
44
+ expect(md).toContain('title: Use SQLite over ChromaDB');
45
+ expect(md).toContain('tags: tech-decision, database');
46
+ expect(md).toContain('related: docs/tech-decision.md');
47
+ expect(md).toContain('# Use SQLite over ChromaDB');
48
+ expect(md).toContain(note.body);
49
+ });
50
+
51
+ it('sanitizes newlines in head fields', () => {
52
+ const note = makeNote({
53
+ title: 'Multi\nline',
54
+ });
55
+
56
+ const md = renderNoteCard(note);
57
+ // title should not have raw newlines in frontmatter
58
+ const fmMatch = md.match(/^title: (.+)$/m);
59
+ expect(fmMatch).not.toBeNull();
60
+ expect(fmMatch![1]!).not.toContain('\n');
61
+ });
62
+
63
+ it('uses body first line as summary', () => {
64
+ const note = makeNote();
65
+ const md = renderNoteCard(note);
66
+ // The first line of body should appear as summary
67
+ expect(md).toContain('summary: A client-supplied key that makes a retried POST safe to replay.');
68
+ });
69
+ });
70
+
71
+ describe('parseNoteCard', () => {
72
+ it('round-trips renderNoteCard -> parseNoteCard preserving head fields', () => {
73
+ const note = makeNote();
74
+ const md = renderNoteCard(note);
75
+ const parsed = parseNoteCard(md);
76
+ expect(parsed).not.toBeNull();
77
+ expect(parsed!.note_kind).toBe('glossary');
78
+ expect(parsed!.title).toBe('Idempotency Key');
79
+ expect(parsed!.tags).toBe('api, payments');
80
+ expect(parsed!.related).toBe('createCharge, charge.ts:88-140');
81
+ // summary from body first line
82
+ expect(parsed!.summary).toBeTruthy();
83
+ });
84
+
85
+ it('returns null for invalid markdown', () => {
86
+ expect(parseNoteCard('no frontmatter')).toBeNull();
87
+ expect(parseNoteCard('')).toBeNull();
88
+ });
89
+
90
+ it('returns null for card without note_kind in frontmatter', () => {
91
+ const md = `---
92
+ name: foo
93
+ kind: function
94
+ ---
95
+ # foo
96
+ `;
97
+ expect(parseNoteCard(md)).toBeNull();
98
+ });
99
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Note (glossary/decision) card rendering and parsing for codewalker v1.3.
3
+ *
4
+ * PURE module — no I/O. Renders a Note into a markdown card
5
+ * with frontmatter head (note_kind, title, tags, related, summary) and body.
6
+ *
7
+ * Cards live at entries/glossary/<slug>.md and entries/decisions/<slug>.md.
8
+ */
9
+
10
+ import type { Note, NoteKind } from "./types.ts";
11
+
12
+ /**
13
+ * Render a Note (glossary term or decision) into a markdown card string.
14
+ *
15
+ * Frontmatter head includes note_kind, title, tags, related, summary.
16
+ * Body starts with `# <title>` and contains the full body text.
17
+ * Summary is the first line of the body.
18
+ */
19
+ export function renderNoteCard(note: Note): string {
20
+ const lines: string[] = ["---"];
21
+
22
+ addNoteHeadField(lines, "note_kind", note.note_kind);
23
+ addNoteHeadField(lines, "title", note.title);
24
+ if (note.tags) addNoteHeadField(lines, "tags", note.tags);
25
+ if (note.related) addNoteHeadField(lines, "related", note.related);
26
+
27
+ // Summary = first line of body
28
+ const summary = note.body.split("\n")[0]?.trim() || note.title;
29
+ addNoteHeadField(lines, "summary", summary);
30
+
31
+ lines.push("---");
32
+ lines.push("");
33
+
34
+ // Body
35
+ lines.push(`# ${note.title}`);
36
+ lines.push("");
37
+
38
+ if (note.body) {
39
+ lines.push(note.body);
40
+ }
41
+
42
+ return lines.join("\n") + "\n";
43
+ }
44
+
45
+ /**
46
+ * Parse a note card from a markdown string.
47
+ * Returns null if the card is invalid or not a note card.
48
+ */
49
+ export function parseNoteCard(text: string): {
50
+ note_kind: NoteKind;
51
+ title: string;
52
+ tags: string;
53
+ related: string;
54
+ summary: string;
55
+ } | null {
56
+ const trimmed = text.trim();
57
+ if (!trimmed.startsWith("---")) return null;
58
+
59
+ const endOfFm = trimmed.indexOf("\n---", 3);
60
+ if (endOfFm === -1) return null;
61
+
62
+ const fmRaw = trimmed.slice(3, endOfFm).trim();
63
+
64
+ // Parse frontmatter lines into a record
65
+ const fm: Record<string, string> = {};
66
+ for (const line of fmRaw.split("\n")) {
67
+ const sep = line.indexOf(":");
68
+ if (sep > 0) {
69
+ const key = line.slice(0, sep).trim();
70
+ const value = line.slice(sep + 1).trim();
71
+ fm[key] = value;
72
+ }
73
+ }
74
+
75
+ const noteKind = fm["note_kind"];
76
+ if (noteKind !== "glossary" && noteKind !== "decision") return null;
77
+ if (!fm["title"]) return null;
78
+
79
+ return {
80
+ note_kind: noteKind as NoteKind,
81
+ title: fm["title"] ?? "",
82
+ tags: fm["tags"] ?? "",
83
+ related: fm["related"] ?? "",
84
+ summary: fm["summary"] ?? "",
85
+ };
86
+ }
87
+
88
+ /** Add a key:value line to the frontmatter, sanitizing newlines. */
89
+ function addNoteHeadField(lines: string[], key: string, value: string): void {
90
+ const safe = value.replace(/\n/g, " ").trim();
91
+ lines.push(`${key}: ${safe}`);
92
+ }
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { openDb, searchNotes } from './db.ts';
6
+ import { addNote, rebuildNotesDbFromCards } from './notes.ts';
7
+
8
+ describe('notes.ts', () => {
9
+ let tmpDir: string;
10
+ let glossaryDir: string;
11
+ let decisionsDir: string;
12
+ let dbPath: string;
13
+
14
+ beforeEach(() => {
15
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-notes-'));
16
+ glossaryDir = path.join(tmpDir, 'glossary');
17
+ decisionsDir = path.join(tmpDir, 'decisions');
18
+ dbPath = path.join(tmpDir, 'test.db');
19
+ fs.mkdirSync(glossaryDir, { recursive: true });
20
+ fs.mkdirSync(decisionsDir, { recursive: true });
21
+ });
22
+
23
+ afterEach(() => {
24
+ fs.rmSync(tmpDir, { recursive: true, force: true });
25
+ });
26
+
27
+ describe('addNote', () => {
28
+ it('writes a glossary card and inserts a DB row', () => {
29
+ addNote(dbPath, {
30
+ note_kind: 'glossary',
31
+ title: 'Idempotency Key',
32
+ body: 'A client-supplied key that makes retries safe.',
33
+ tags: 'api,payments',
34
+ related: 'createCharge',
35
+ card_path: '',
36
+ }, glossaryDir);
37
+
38
+ // Card file exists under glossary dir
39
+ const files = fs.readdirSync(glossaryDir);
40
+ expect(files.length).toBeGreaterThan(0);
41
+ const cardFile = files.find((f) => f.endsWith('.md'));
42
+ expect(cardFile).toBeDefined();
43
+
44
+ // Card content has frontmatter
45
+ const cardContent = fs.readFileSync(path.join(glossaryDir, cardFile!), 'utf-8');
46
+ expect(cardContent).toContain('note_kind: glossary');
47
+ expect(cardContent).toContain('title: Idempotency Key');
48
+
49
+ // DB row exists
50
+ const db = openDb(dbPath);
51
+ const results = searchNotes(db, 'Idempotency');
52
+ expect(results).toHaveLength(1);
53
+ expect(results[0]!.name).toBe('Idempotency Key');
54
+ expect(results[0]!.note_kind).toBe('glossary');
55
+ db.close();
56
+ });
57
+
58
+ it('writes a decision card under decisions dir', () => {
59
+ addNote(dbPath, {
60
+ note_kind: 'decision',
61
+ title: 'Use SQLite over ChromaDB',
62
+ body: 'Chosen for zero-infra approach.',
63
+ tags: 'tech-decision',
64
+ related: '',
65
+ card_path: '',
66
+ }, decisionsDir);
67
+
68
+ const files = fs.readdirSync(decisionsDir);
69
+ const cardFile = files.find((f) => f.endsWith('.md'));
70
+ expect(cardFile).toBeDefined();
71
+
72
+ const cardContent = fs.readFileSync(path.join(decisionsDir, cardFile!), 'utf-8');
73
+ expect(cardContent).toContain('note_kind: decision');
74
+ expect(cardContent).toContain('title: Use SQLite over ChromaDB');
75
+ });
76
+
77
+ it('is idempotent — re-adding same note updates in place', () => {
78
+ addNote(dbPath, {
79
+ note_kind: 'glossary', title: 'Term',
80
+ body: 'Version 1', tags: '', related: '',
81
+ card_path: '',
82
+ }, glossaryDir);
83
+
84
+ addNote(dbPath, {
85
+ note_kind: 'glossary', title: 'Term',
86
+ body: 'Version 2 updated', tags: 'v2', related: '',
87
+ card_path: '',
88
+ }, glossaryDir);
89
+
90
+ // Single card file
91
+ const files = fs.readdirSync(glossaryDir).filter((f) => f.endsWith('.md'));
92
+ expect(files).toHaveLength(1);
93
+
94
+ // DB has single row with updated body
95
+ const db = openDb(dbPath);
96
+ const results = searchNotes(db, '');
97
+ expect(results).toHaveLength(1);
98
+ expect(results[0]!.summary).toContain('updated');
99
+ db.close();
100
+ });
101
+ });
102
+
103
+ describe('rebuildNotesDbFromCards', () => {
104
+ it('reconstructs DB rows from glossary + decisions cards alone', () => {
105
+ // Add two notes
106
+ addNote(dbPath, {
107
+ note_kind: 'glossary', title: 'Cache Stampede',
108
+ body: 'When many requests miss cache simultaneously.', tags: 'cache',
109
+ related: 'getOrCreate',
110
+ card_path: '',
111
+ }, glossaryDir);
112
+ addNote(dbPath, {
113
+ note_kind: 'decision', title: 'Use triggers for FTS',
114
+ body: 'FTS triggers prevent index corruption.', tags: 'sqlite',
115
+ related: 'db.ts',
116
+ card_path: '',
117
+ }, decisionsDir);
118
+
119
+ // Delete the DB to simulate disposable-index property
120
+ fs.rmSync(dbPath, { force: true });
121
+
122
+ // Rebuild from cards
123
+ rebuildNotesDbFromCards(dbPath, glossaryDir, decisionsDir);
124
+
125
+ // Query should find both
126
+ const db = openDb(dbPath);
127
+ const results = searchNotes(db, '');
128
+ expect(results).toHaveLength(2);
129
+
130
+ const glossaryHits = results.filter((r) => r.note_kind === 'glossary');
131
+ const decisionHits = results.filter((r) => r.note_kind === 'decision');
132
+ expect(glossaryHits).toHaveLength(1);
133
+ expect(decisionHits).toHaveLength(1);
134
+ expect(glossaryHits[0]!.name).toBe('Cache Stampede');
135
+ expect(decisionHits[0]!.name).toBe('Use triggers for FTS');
136
+
137
+ db.close();
138
+ });
139
+
140
+ it('handles empty directories gracefully', () => {
141
+ // No cards added — should not throw
142
+ fs.rmSync(dbPath, { force: true });
143
+ rebuildNotesDbFromCards(dbPath, glossaryDir, decisionsDir);
144
+ const db = openDb(dbPath);
145
+ const results = searchNotes(db, '');
146
+ expect(results).toHaveLength(0);
147
+ db.close();
148
+ });
149
+
150
+ it('preserves card_path in rebuilt rows', () => {
151
+ addNote(dbPath, {
152
+ note_kind: 'glossary', title: 'Idempotency Key',
153
+ body: 'Retry-safe POST key.', tags: '', related: '',
154
+ card_path: '',
155
+ }, glossaryDir);
156
+
157
+ // Get the card path
158
+ const files = fs.readdirSync(glossaryDir).filter((f) => f.endsWith('.md'));
159
+ const cardPath = path.join(glossaryDir, files[0]!);
160
+
161
+ fs.rmSync(dbPath, { force: true });
162
+ rebuildNotesDbFromCards(dbPath, glossaryDir, decisionsDir);
163
+
164
+ const db = openDb(dbPath);
165
+ const row = db.prepare("SELECT card_path FROM notes WHERE title = ?").get('Idempotency Key') as any;
166
+ expect(row).not.toBeUndefined();
167
+ // card_path might be absolute or relative depending on how we stored it
168
+ expect(row.card_path).toBeTruthy();
169
+ db.close();
170
+ });
171
+ });
172
+ });
package/src/notes.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Note orchestration for codewalker v1.3.
3
+ *
4
+ * Provides:
5
+ * - `addNote()`: render card → atomic write → upsertNote
6
+ * - `rebuildNotesDbFromCards()`: disposable-index rebuild from card files
7
+ *
8
+ * Mirrors the style of src/libs/indexer.ts (atomic write, tmp + rename, mode 0o600).
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { openDb, upsertNote } from "./db.ts";
14
+ import { renderNoteCard, parseNoteCard } from "./notes-cards.ts";
15
+ import type { Note, NoteKind } from "./types.ts";
16
+
17
+ /**
18
+ * Write a note: render card → atomic write → upsert DB row.
19
+ *
20
+ * The note's note_kind determines which directory to use.
21
+ * When `notesDir` is provided, it is used directly (backward compat).
22
+ * When both `glossaryDir` and `decisionsDir` are available, pass them via an options bag.
23
+ */
24
+ export function addNote(
25
+ dbPath: string,
26
+ note: Note,
27
+ notesDir?: string,
28
+ ): void {
29
+ const dir = notesDir;
30
+ if (!dir) throw new Error("notesDir is required");
31
+
32
+ // Ensure directory exists
33
+ fs.mkdirSync(dir, { recursive: true });
34
+
35
+ // Generate slug from title
36
+ const slug = slugifyNoteTitle(note.title);
37
+ const cardPath = path.join(dir, `${slug}.md`);
38
+
39
+ // Render card
40
+ const card = renderNoteCard(note);
41
+
42
+ // Atomic write
43
+ const tmpPath = cardPath + ".tmp";
44
+ fs.writeFileSync(tmpPath, card, { encoding: "utf-8", mode: 0o600 });
45
+ fs.renameSync(tmpPath, cardPath);
46
+
47
+ // Upsert DB row with the actual card_path
48
+ const db = openDb(dbPath);
49
+ try {
50
+ upsertNote(db, {
51
+ ...note,
52
+ card_path: cardPath,
53
+ });
54
+ } finally {
55
+ db.close();
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Rebuild the notes DB tables from glossary + decisions cards alone.
61
+ * Demonstrates the disposable-index property: cards are the source of truth.
62
+ */
63
+ export function rebuildNotesDbFromCards(
64
+ dbPath: string,
65
+ glossaryDir: string,
66
+ decisionsDir: string,
67
+ ): void {
68
+ const db = openDb(dbPath);
69
+
70
+ try {
71
+ db.exec("BEGIN TRANSACTION");
72
+
73
+ // Clear existing notes
74
+ db.prepare("DELETE FROM notes").run();
75
+
76
+ // Process glossary cards
77
+ if (fs.existsSync(glossaryDir)) {
78
+ processCardsInDir(db, glossaryDir, "glossary");
79
+ }
80
+
81
+ // Process decisions cards
82
+ if (fs.existsSync(decisionsDir)) {
83
+ processCardsInDir(db, decisionsDir, "decision");
84
+ }
85
+
86
+ db.exec("COMMIT");
87
+ } catch (e) {
88
+ db.exec("ROLLBACK");
89
+ throw e;
90
+ } finally {
91
+ db.close();
92
+ }
93
+ }
94
+
95
+ // ── Internal helpers ───────────────────────────────────────────
96
+
97
+ function processCardsInDir(
98
+ db: ReturnType<typeof openDb>,
99
+ dir: string,
100
+ expectedKind: NoteKind,
101
+ ): void {
102
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
103
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
104
+
105
+ const cardPath = path.join(dir, entry.name);
106
+ const cardText = fs.readFileSync(cardPath, "utf-8");
107
+ const parsed = parseNoteCard(cardText);
108
+ if (!parsed) continue;
109
+ if (parsed.note_kind !== expectedKind) continue;
110
+
111
+ // Extract body from card
112
+ const body = extractNoteBody(cardText);
113
+
114
+ upsertNote(db, {
115
+ note_kind: parsed.note_kind,
116
+ title: parsed.title,
117
+ body,
118
+ tags: parsed.tags,
119
+ related: parsed.related,
120
+ card_path: cardPath,
121
+ });
122
+ }
123
+ }
124
+
125
+ /** Extract the body (after frontmatter) from a note card, stripping the # title header. */
126
+ function extractNoteBody(cardText: string): string {
127
+ const trimmed = cardText.trim();
128
+ if (!trimmed.startsWith("---")) return "";
129
+
130
+ const endOfFm = trimmed.indexOf("\n---", 3);
131
+ if (endOfFm === -1) return "";
132
+
133
+ return trimmed.slice(endOfFm + 4).trim();
134
+ }
135
+
136
+ function slugifyNoteTitle(title: string): string {
137
+ return title
138
+ .toLowerCase()
139
+ .replace(/[^a-z0-9]+/g, "-")
140
+ .replace(/^-+|-+$/g, "")
141
+ .slice(0, 60) || "untitled";
142
+ }
143
+
144
+ // Re-export for convenience
145
+ export { slugifyNoteTitle };
@@ -80,6 +80,14 @@ describe('project.ts', () => {
80
80
  expect(p.metaFile).toBe(path.join(p.codewalkerDir, 'meta.json'));
81
81
  expect(p.entriesDir).toBe(path.join(p.codewalkerDir, 'entries'));
82
82
  expect(p.symbolsDir).toBe(path.join(p.codewalkerDir, 'entries', 'symbols'));
83
+ expect(p.libsDir).toBe(path.join(p.codewalkerDir, 'entries', 'libs'));
84
+ });
85
+
86
+ it('exposes glossaryDir and decisionsDir paths', async () => {
87
+ const mod = await import('./project.ts');
88
+ const p = mod.resolveProject(tmpDir);
89
+ expect(p.glossaryDir).toBe(path.join(p.codewalkerDir, 'entries', 'glossary'));
90
+ expect(p.decisionsDir).toBe(path.join(p.codewalkerDir, 'entries', 'decisions'));
83
91
  });
84
92
  });
85
93
 
@@ -89,10 +97,13 @@ describe('project.ts', () => {
89
97
  const p = await mod.ensureProject(tmpDir);
90
98
  // marker file exists
91
99
  expect(fs.existsSync(p.markerPath)).toBe(true);
92
- // codewalker dir is created
100
+ // codewalker dir + subdirs are created
93
101
  expect(fs.existsSync(p.codewalkerDir)).toBe(true);
94
102
  expect(fs.existsSync(p.entriesDir)).toBe(true);
95
103
  expect(fs.existsSync(p.symbolsDir)).toBe(true);
104
+ expect(fs.existsSync(p.libsDir)).toBe(true);
105
+ expect(fs.existsSync(p.glossaryDir)).toBe(true);
106
+ expect(fs.existsSync(p.decisionsDir)).toBe(true);
96
107
  // meta.json written
97
108
  expect(fs.existsSync(p.metaFile)).toBe(true);
98
109
  // meta.json has correct shape
package/src/project.ts CHANGED
@@ -31,6 +31,8 @@ export interface ProjectPaths {
31
31
  entriesDir: string;
32
32
  symbolsDir: string;
33
33
  libsDir: string;
34
+ glossaryDir: string;
35
+ decisionsDir: string;
34
36
  }
35
37
 
36
38
  function piHome(): string {
@@ -125,6 +127,8 @@ function pathsForId(id: string, root: string, configDir: string, markerPath: str
125
127
  entriesDir: path.join(globalDir, "codewalker", "entries"),
126
128
  symbolsDir: path.join(globalDir, "codewalker", "entries", "symbols"),
127
129
  libsDir: path.join(globalDir, "codewalker", "entries", "libs"),
130
+ glossaryDir: path.join(globalDir, "codewalker", "entries", "glossary"),
131
+ decisionsDir: path.join(globalDir, "codewalker", "entries", "decisions"),
128
132
  };
129
133
  }
130
134
 
@@ -166,7 +170,7 @@ export async function ensureProject(cwd: string): Promise<ProjectPaths> {
166
170
  const p = resolveProject(cwd);
167
171
  const nowISO = new Date().toISOString();
168
172
 
169
- for (const dir of [p.codewalkerDir, p.entriesDir, p.symbolsDir, p.libsDir]) {
173
+ for (const dir of [p.codewalkerDir, p.entriesDir, p.symbolsDir, p.libsDir, p.glossaryDir, p.decisionsDir]) {
170
174
  fs.mkdirSync(dir, { recursive: true });
171
175
  }
172
176
 
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, searchLibSymbols } from './db.ts';
5
+ import { openDb, upsertSymbol, setMeta, getMeta, upsertLibSymbol, upsertNote, searchLibSymbols } from './db.ts';
6
6
  import { runQuery } from './query.ts';
7
7
 
8
8
  describe('query.ts', () => {
@@ -166,4 +166,80 @@ describe('query.ts', () => {
166
166
  const result = runQuery(dbPath, { query: '', source: 'all', limit: 1 });
167
167
  expect(result.rows).toHaveLength(1);
168
168
  });
169
+
170
+ // ── notes source ───────────────────────────────────────────
171
+ it('source="notes" returns only notes', () => {
172
+ const db = openDb(dbPath);
173
+ upsertSymbol(db, {
174
+ name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
175
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
176
+ });
177
+ upsertNote(db, {
178
+ note_kind: 'glossary', title: 'Glossary Term', body: 'A term', tags: '', related: '', card_path: '',
179
+ });
180
+ db.close();
181
+
182
+ const result = runQuery(dbPath, { query: '', source: 'notes' });
183
+ expect(result.rows).toHaveLength(1);
184
+ expect(result.rows[0]!.name).toBe('Glossary Term');
185
+ expect(result.rows[0]!.source).toBe('note');
186
+ });
187
+
188
+ it('source="all" includes notes interleaved with code and libs', () => {
189
+ const db = openDb(dbPath);
190
+ upsertSymbol(db, {
191
+ name: 'myFunc', kind: 'function', file_path: 'src/a.ts',
192
+ line_start: 1, line_end: 1, signature: '', doc: 'refresh token', summary: '', card_path: '',
193
+ });
194
+ upsertLibSymbol(db, {
195
+ lib: 'hono', version: '4.6.3', name: 'honoFunc',
196
+ kind: 'function', signature: '', doc: 'refresh token', summary: '', card_path: '',
197
+ });
198
+ upsertNote(db, {
199
+ note_kind: 'glossary', title: 'Refresh Token', body: 'A token used to refresh auth without re-login.', tags: 'auth', related: 'myFunc', card_path: '',
200
+ });
201
+ db.close();
202
+
203
+ const result = runQuery(dbPath, { query: 'refresh', source: 'all' });
204
+ expect(result.rows.length).toBeGreaterThanOrEqual(2);
205
+ // Should have note + lib + code (code rows have source undefined)
206
+ const sources = result.rows.map(r => r.source);
207
+ expect(sources).toContain('note');
208
+ expect(sources).toContain('lib');
209
+ expect(sources).toContain(undefined); // code rows have no source
210
+ });
211
+
212
+ it('source kind filter works for notes source', () => {
213
+ const db = openDb(dbPath);
214
+ upsertNote(db, {
215
+ note_kind: 'glossary', title: 'Term', body: 'A glossary term', tags: '', related: '', card_path: '',
216
+ });
217
+ upsertNote(db, {
218
+ note_kind: 'decision', title: 'Decision', body: 'A decision note', tags: '', related: '', card_path: '',
219
+ });
220
+ db.close();
221
+
222
+ const result = runQuery(dbPath, { query: '', source: 'notes', kind: 'glossary' });
223
+ expect(result.rows).toHaveLength(1);
224
+ expect(result.rows[0]!.name).toBe('Term');
225
+ });
226
+
227
+ it('source=all with note in query works (note matches alongside code)', () => {
228
+ const db = openDb(dbPath);
229
+ upsertSymbol(db, {
230
+ name: 'myFunc', kind: 'function', file_path: 'src/payments.ts',
231
+ line_start: 1, line_end: 10, signature: '', doc: 'processes charges', summary: '', card_path: '',
232
+ });
233
+ upsertNote(db, {
234
+ note_kind: 'glossary', title: 'charge', body: 'A payment processing concept.',
235
+ tags: 'payments', related: 'myFunc', card_path: '',
236
+ });
237
+ db.close();
238
+
239
+ const result = runQuery(dbPath, { query: 'charge', source: 'all' });
240
+ expect(result.rows.length).toBeGreaterThanOrEqual(1);
241
+ const sources = result.rows.map(r => r.source);
242
+ expect(sources).toContain('note');
243
+ });
244
+
169
245
  });