@aprimediet/codewalker 1.0.0 → 1.1.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/README.md CHANGED
@@ -1,71 +1,65 @@
1
1
  # @aprimediet/codewalker
2
2
 
3
- Systematic **project intelligence** for the [pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent): analyze tech stack, goals, boundaries, status, and technical issues, then generate a **PRD** for humans and **AGENTS.md** / **CLAUDE.md** for coding agents — plus integration with `@aprimediet/minion` and `@aprimediet/memory`.
3
+ Queryable, token-economical project & code index for the pi coding agent.
4
4
 
5
- Documentation is split by audience: `docs/PRD.md` holds the product *what & why* (overview, goals, users, features, metrics); `AGENTS.md` holds the engineering *how* (tech stack, structure, commands, conventions, technical boundaries, gotchas); `CLAUDE.md` is a thin pointer that imports `AGENTS.md` as the single source of truth.
5
+ ## What
6
6
 
7
- ## Phases
7
+ Instead of blindly scanning files (glob → grep → read) on every request — which floods the
8
+ context window with irrelevant bytes — codewalker builds a **mechanical code index** once,
9
+ out of band, and lets the agent query it with compact, ranked results.
8
10
 
9
- | Phase | What it does |
10
- |-------|---|
11
- | Phase 1 | Detect primary language, frameworks, infrastructure, and package manager from manifest files |
12
- | Phase 2 | Find or gather project goals and non-goals (interactively, one question at a time) |
13
- | Phase 3 | Document key entry points, external services, exposed interfaces, and technical constraints |
14
- | Phase 4 | Scan recent commit history and search for technical debt markers (TODO/FIXME/HACK/BUG) |
15
- | Phase 5 | Detect missing test directories, broken env files, invalid configs, and TypeScript strictness issues |
16
- | Phase 6 | Generate docs, split by audience: `docs/PRD.md` (product), `AGENTS.md` (engineering), `CLAUDE.md` (pointer to AGENTS.md) — each with user confirmation |
17
- | Phase 7 | Detect active `@aprimediet/minion` and `@aprimediet/memory` integrations via the shared `.pi/<id>.md` marker |
18
- | Phase 8 | Compile full project intelligence document with all sections |
19
- | Phase 9 | Store summary to memory (if active) or save to local file |
11
+ **Token economy is the north star.** Pay the file scan once, outside the LLM context. Query
12
+ cheaply, many times.
20
13
 
21
- ## Install
14
+ ## Architecture
22
15
 
23
- ```bash
24
- pi install npm:@aprimediet/codewalker
25
- pi list
26
16
  ```
27
-
28
- ## Quick try
29
-
30
- ```bash
31
- pi -e ./extensions/codewalker/index.ts
17
+ source files ──→ [ctags / regex] ──→ Symbol[]
18
+
19
+ renderCard() → .md files (source of truth)
20
+
21
+ index.db (SQLite + FTS5, disposable)
22
+
23
+ codewalker_query (compact results)
32
24
  ```
33
25
 
34
- Then run `/learn-this` in any project.
26
+ - **Cards are the source of truth** — markdown in `~/.pi/projects/<id>/codewalker/entries/`.
27
+ - **SQLite + FTS5 is a disposable index** — rebuildable from cards at any time.
28
+ - **ctags primary, regex fallback** — ctags used when available, regex for TS/JS/Py/Go.
29
+ - **Git-anchored** — stale index detected per query.
35
30
 
36
- ## Integration
31
+ ## Commands
37
32
 
38
- codewalker automatically detects the presence of two companion extensions via a shared project marker:
33
+ | Command | Description |
34
+ |---------|-------------|
35
+ | `/codewalker scan` | Full (re)build — walks project tree, extracts symbols, writes cards, populates DB |
36
+ | `/codewalker sync` | Git-anchored incremental — reindexes only changed files |
37
+ | `/codewalker query <text>` | Search the index (compact results) |
39
38
 
40
- **Minion Integration (read-only):**
41
- - Reads the `.pi/<project-id>.md` marker file from the current working directory
42
- - Checks `~/.pi/projects/<id>/tasks/` for open kanban cards (backlog, todo, in_progress, blocked, review)
43
- - Counts and reports open task count during the `/learn-this` summary
39
+ ## Tool
44
40
 
45
- **Memory Integration (read-only + write):**
46
- - Reads the same `.pi/<project-id>.md` marker file
47
- - Checks `~/.pi/projects/<id>/memory/` for active memory (MEMORY.md + entries/)
48
- - Counts memory entries and reads the index during Phase 7
49
- - In Phase 9, if memory is active, calls `memory_write` with scope `"project"` to store the full intelligence snapshot
41
+ The model can call `codewalker_query` directly. Returns `content` (compact text) + `details` (full rows).
50
42
 
51
- Both integrations use **read-only detection** (no creation of files or directories); the marker file is created and managed by minion or memory when activated. codewalker coexists with them seamlessly in the same `~/.pi/projects/<id>/` workspace.
43
+ ## Install
52
44
 
53
- ## Layout
45
+ ```bash
46
+ # In the pi extensions directory:
47
+ npm install @aprimediet/codewalker
48
+ ```
54
49
 
50
+ Then load the extension:
51
+ ```
52
+ pi -e ./node_modules/@aprimediet/codewalker/index.ts
55
53
  ```
56
- codewalker/ # @aprimediet/codewalker
57
- ├── package.json # pi manifest: extensions + skills
58
- ├── index.ts # extension factory: /learn-this command (probes + triggers the workflow)
59
- ├── compat.ts # minion + memory integration detection
60
- ├── detect.ts # phase-specific file and marker detection
61
- ├── prd.ts # PRD (human) search, read, and generation
62
- ├── agents.ts # AGENTS.md + CLAUDE.md (agent) search and generation
63
- └── skills/
64
- └── learn-this/
65
- └── SKILL.md # 9-phase intelligence gathering workflow + checklist
54
+
55
+ ## Development
56
+
57
+ ```bash
58
+ npm install
59
+ npm test # vitest 86+ tests across 12 test files
60
+ npm run test:watch # watch mode
66
61
  ```
67
62
 
68
- The `/learn-this` command probes integration status, shows it, then sends a user message
69
- that triggers the agent to invoke the `learn-this` skill and run all 9 phases.
63
+ ## License
70
64
 
71
- No third-party runtime deps — only the pi-core packages (peer, bundled by pi).
65
+ MIT
package/index.ts CHANGED
@@ -1,43 +1,7 @@
1
- import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { probeCompat } from "./compat.ts";
1
+ /**
2
+ * @aprimediet/codewalker extension entry point.
3
+ *
4
+ * Re-exports the factory from src/ so pi finds the extension at root level.
5
+ */
6
+ export { default } from "./src/index.ts";
3
7
 
4
- export default function codewalkExtension(pi: ExtensionAPI): void {
5
- pi.registerCommand("learn-this", {
6
- description: "Analyze this project: tech stack, goals, status, issues, then generate PRD + AGENTS.md/CLAUDE.md.",
7
- handler: async (_args, ctx: ExtensionContext) => {
8
- const compat = probeCompat(ctx.cwd);
9
-
10
- const minionLine = compat.minionActive
11
- ? `minion: active (project ${compat.projectId}, ${compat.openTaskCount} open tasks)`
12
- : "minion: not detected";
13
-
14
- const memoryLine = compat.memoryActive
15
- ? `memory: active (${compat.memoryEntries} entries)`
16
- : "memory: not detected";
17
-
18
- // Surface the probe result to the user immediately (TUI only).
19
- if (ctx.hasUI) {
20
- ctx.ui.notify(
21
- ["codewalker: starting /learn-this", ` ${minionLine}`, ` ${memoryLine}`].join("\n"),
22
- "info",
23
- );
24
- }
25
-
26
- // Actually kick off the workflow: send a user message that triggers an agent turn.
27
- // The agent invokes the learn-this skill and runs all 9 phases. We hand it the
28
- // integration facts up front so Phase 7 (and Phase 9 storage) start from real data.
29
- const directive = [
30
- "Run the /learn-this project intelligence workflow on the current working directory now.",
31
- "Invoke the `learn-this` skill and complete every phase and checklist item in order.",
32
- "Do not stop after detection — gather goals interactively (one question at a time) where they are missing, generate the docs (docs/PRD.md for humans, AGENTS.md + CLAUDE.md for coding agents, with the audience-correct content split), and finish with the summary and memory storage steps.",
33
- "",
34
- "Integration status detected by codewalker (use these facts in Phase 7 and Phase 9):",
35
- `- ${minionLine}`,
36
- `- ${memoryLine}`,
37
- compat.projectId ? `- project id: ${compat.projectId}` : "- project id: none (no .pi marker found)",
38
- ].join("\n");
39
-
40
- pi.sendUserMessage(directive, { deliverAs: "followUp" });
41
- },
42
- });
43
- }
package/package.json CHANGED
@@ -1,47 +1,28 @@
1
1
  {
2
2
  "name": "@aprimediet/codewalker",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
- "description": "Project intelligence snapshot for the pi coding agent — /learn-this",
6
- "keywords": [
7
- "pi-package",
8
- "pi-extension",
9
- "codewalker",
10
- "project-intelligence",
11
- "prd",
12
- "agents-md"
13
- ],
14
- "license": "MIT",
15
- "author": {
16
- "name": "aprimediet",
17
- "url": "https://github.com/aprimediet"
18
- },
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/aprimediet/codewalker.git"
22
- },
23
- "bugs": {
24
- "url": "https://github.com/aprimediet/codewalker/issues"
25
- },
26
- "homepage": "https://github.com/aprimediet/codewalker#readme",
27
- "engines": {
28
- "node": ">=18"
29
- },
30
- "publishConfig": {
31
- "access": "public"
32
- },
5
+ "description": "Queryable, token-economical project & code index for the pi coding agent.",
6
+ "keywords": ["pi-package"],
33
7
  "pi": {
34
8
  "extensions": ["./index.ts"],
35
- "skills": ["./skills"]
9
+ "skills": ["./skills"],
10
+ "prompts": ["./prompts"]
11
+ },
12
+ "scripts": {
13
+ "test": "vitest run",
14
+ "test:watch": "vitest"
36
15
  },
37
- "files": [
38
- "*.ts",
39
- "skills/**",
40
- "README.md",
41
- "LICENSE",
42
- "docs/**"
43
- ],
16
+ "files": ["*.ts", "src/**", "skills/**", "prompts/**", "README.md"],
44
17
  "peerDependencies": {
45
- "@earendil-works/pi-coding-agent": "*"
46
- }
18
+ "@earendil-works/pi-coding-agent": "*",
19
+ "@earendil-works/pi-agent-core": "*",
20
+ "@earendil-works/pi-ai": "*",
21
+ "@earendil-works/pi-tui": "*",
22
+ "typebox": "*"
23
+ },
24
+ "dependencies": { "better-sqlite3": "^11.0.0" },
25
+ "devDependencies": { "vitest": "^1.6.0", "@types/better-sqlite3": "^7.6.0" },
26
+ "engines": { "node": ">=20" },
27
+ "publishConfig": { "access": "public" }
47
28
  }
@@ -0,0 +1,7 @@
1
+ You have access to the codewalker code index for this project. Before reading or grepping
2
+ files to find symbols (functions, consts, classes, types), use `codewalker_query` to
3
+ look them up. The query returns compact facts — name, kind, file:line, and a one-line
4
+ summary.
5
+
6
+ - If the index is stale (shown in the result), run `/codewalker sync`.
7
+ - For a full index, run `/codewalker scan`.
@@ -0,0 +1,43 @@
1
+ # Codewalker — Queryable Code Index
2
+
3
+ **Use this skill when:** you need to understand a codebase — find where a symbol is defined,
4
+ understand what a function does, or check if a const/class/type exists — BEFORE editing
5
+ files or grepping through the repo.
6
+
7
+ ## Workflow
8
+
9
+ 1. **Always query first** before editing unfamiliar code:
10
+ ```
11
+ /codewalker query "<symbol-name or concept>"
12
+ ```
13
+ This returns compact facts: `name · kind · file:line · one-line summary`.
14
+
15
+ 2. **If the query returns relevant hits**, use `file:line` to read only the span you need
16
+ instead of grepping the whole repo.
17
+
18
+ 3. **If the query returns no hits**, the index may be stale or missing. Run:
19
+ ```
20
+ /codewalker scan
21
+ ```
22
+ (first run) or `/codewalker sync` (incremental update).
23
+
24
+ 4. **When results include a staleness warning** (`indexed @abc, HEAD @def`), run
25
+ `/codewalker sync` before trusting the results.
26
+
27
+ ## Why
28
+
29
+ The index is built out-of-band (mechanical ctags/regex pass) so you never pay the file-scan
30
+ cost inside the LLM context. Queries return compact, ranked facts — tens of tokens instead
31
+ of thousands.
32
+
33
+ ## Commands
34
+
35
+ | Command | Purpose |
36
+ |---------|---------|
37
+ | `/codewalker scan` | Full (re)build of the code index |
38
+ | `/codewalker sync` | Git-anchored incremental update |
39
+ | `/codewalker query <text>` | Search symbols by name or keyword |
40
+
41
+ ## Tool
42
+
43
+ The model can also call `codewalker_query` directly (same behavior as the command).
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderCard, parseCard, cardHead } from './cards.ts';
3
+ import type { Symbol } from './types.ts';
4
+
5
+ function makeSymbol(overrides: Partial<Symbol> = {}): Symbol {
6
+ return {
7
+ name: 'probeCompat',
8
+ kind: 'function',
9
+ file_path: '/root/src/compat.ts',
10
+ line_start: 201,
11
+ line_end: 243,
12
+ signature: '(cwd: string) => CompatResult',
13
+ doc: 'Detect whether minion & memory are active for the project at cwd.',
14
+ summary: '',
15
+ card_path: '',
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ describe('renderCard', () => {
21
+ it('renders a symbol as a markdown card with frontmatter head and body', () => {
22
+ const sym = makeSymbol();
23
+ const md = renderCard(sym);
24
+
25
+ // Head (frontmatter)
26
+ expect(md).toContain('---');
27
+ expect(md).toContain('name: probeCompat');
28
+ expect(md).toContain('kind: function');
29
+ expect(md).toContain('signature: (cwd: string) => CompatResult');
30
+ expect(md).toContain('location: compat.ts:201-243');
31
+ expect(md).toContain('summary: Detect whether minion & memory are active for the project at cwd.');
32
+
33
+ // Body
34
+ expect(md).toContain('# probeCompat');
35
+ expect(md).toContain(sym.doc);
36
+ });
37
+
38
+ it('uses relative path (basename only) for location', () => {
39
+ const sym = makeSymbol({ file_path: '/very/deep/src/util/helper.ts', line_start: 5, line_end: 10 });
40
+ const md = renderCard(sym);
41
+ expect(md).toContain('location: helper.ts:5-10');
42
+ });
43
+
44
+ it('handles empty doc gracefully', () => {
45
+ const sym = makeSymbol({ doc: '' });
46
+ const md = renderCard(sym);
47
+ expect(md).toContain('name: probeCompat');
48
+ expect(md).toContain('# probeCompat');
49
+ // Body may be empty
50
+ });
51
+ });
52
+
53
+ describe('parseCard', () => {
54
+ it('round-trips renderCard → parseCard preserving head fields', () => {
55
+ const sym = makeSymbol();
56
+ const md = renderCard(sym);
57
+ const parsed = parseCard(md);
58
+ expect(parsed).not.toBeNull();
59
+ expect(parsed!.head.name).toBe('probeCompat');
60
+ expect(parsed!.head.kind).toBe('function');
61
+ expect(parsed!.head.signature).toBe('(cwd: string) => CompatResult');
62
+ expect(parsed!.head.location).toBe('compat.ts:201-243');
63
+ expect(parsed!.head.summary).toContain('minion');
64
+ // Body contains the doc text
65
+ expect(parsed!.body).toContain(sym.doc);
66
+ });
67
+
68
+ it('returns null for invalid markdown', () => {
69
+ expect(parseCard('not frontmatter')).toBeNull();
70
+ expect(parseCard('')).toBeNull();
71
+ });
72
+ });
73
+
74
+ describe('cardHead', () => {
75
+ it('returns only the frontmatter head from a card', () => {
76
+ const sym = makeSymbol();
77
+ const md = renderCard(sym);
78
+ const head = cardHead(md);
79
+ expect(head).not.toBeNull();
80
+ expect(head!.name).toBe('probeCompat');
81
+ expect(head!.kind).toBe('function');
82
+ expect(head!.location).toBe('compat.ts:201-243');
83
+ });
84
+
85
+ it('returns null for invalid input', () => {
86
+ expect(cardHead('no frontmatter here')).toBeNull();
87
+ });
88
+ });
package/src/cards.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Markdown card rendering and parsing for codewalker.
3
+ *
4
+ * A card has a frontmatter head (agent-cheap, compact) and a body (human-rich, verbose).
5
+ * The head is what `query` returns; the full card is only expanded on demand.
6
+ *
7
+ * PURE module — no I/O.
8
+ */
9
+
10
+ import type { Symbol, CardHead } from "./types.ts";
11
+ import * as path from "node:path";
12
+
13
+ /**
14
+ * Render a Symbol into a markdown card string with frontmatter head + body.
15
+ */
16
+ export function renderCard(symbol: Symbol): string {
17
+ const location = `${path.basename(symbol.file_path)}:${symbol.line_start}-${symbol.line_end}`;
18
+ const name = symbol.name;
19
+ const summary = symbol.summary || symbol.doc.split("\n")[0]?.trim() || "";
20
+
21
+ const frontmatter = [
22
+ "---",
23
+ `name: ${name}`,
24
+ `kind: ${symbol.kind}`,
25
+ `signature: ${symbol.signature || ""}`,
26
+ `location: ${location}`,
27
+ `summary: ${summary}`,
28
+ "---",
29
+ ].join("\n");
30
+
31
+ const body = symbol.doc
32
+ ? [`# ${name}`, "", symbol.doc].join("\n")
33
+ : `# ${name}`;
34
+
35
+ return `${frontmatter}\n\n${body}\n`;
36
+ }
37
+
38
+ /**
39
+ * Parse a markdown card string into head + body.
40
+ * Returns null if the card is invalid.
41
+ */
42
+ export function parseCard(text: string): { head: CardHead; body: string } | null {
43
+ const trimmed = text.trim();
44
+ if (!trimmed.startsWith("---")) return null;
45
+
46
+ // Find the closing ---
47
+ const endOfFm = trimmed.indexOf("\n---", 3);
48
+ if (endOfFm === -1) return null;
49
+
50
+ const fmRaw = trimmed.slice(3, endOfFm).trim();
51
+ const body = trimmed.slice(endOfFm + 4).trim();
52
+
53
+ // Parse frontmatter lines
54
+ const fm: Record<string, string> = {};
55
+ for (const line of fmRaw.split("\n")) {
56
+ const sep = line.indexOf(":");
57
+ if (sep > 0) {
58
+ const key = line.slice(0, sep).trim();
59
+ const value = line.slice(sep + 1).trim();
60
+ fm[key] = value;
61
+ }
62
+ }
63
+
64
+ if (!fm["name"]) return null;
65
+
66
+ return {
67
+ head: {
68
+ name: fm["name"] ?? "",
69
+ kind: fm["kind"] ?? "",
70
+ signature: fm["signature"] ?? "",
71
+ location: fm["location"] ?? "",
72
+ tags: (fm["tags"] ?? "").split(",").map((t) => t.trim()).filter(Boolean),
73
+ summary: fm["summary"] ?? "",
74
+ },
75
+ body,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Extract only the frontmatter head from a card — the compact, agent-cheap view.
81
+ * Returns null if the card is invalid.
82
+ */
83
+ export function cardHead(text: string): CardHead | null {
84
+ const parsed = parseCard(text);
85
+ if (!parsed) return null;
86
+ return parsed.head;
87
+ }
package/src/db.test.ts ADDED
@@ -0,0 +1,197 @@
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 Database from 'better-sqlite3';
6
+ import { openDb, bootstrapDb, upsertSymbol, searchSymbols, getMeta, setMeta, deleteFileSymbols } from './db.ts';
7
+
8
+ describe('db.ts', () => {
9
+ let tmpDir: string;
10
+ let dbPath: string;
11
+
12
+ beforeEach(() => {
13
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-db-'));
14
+ dbPath = path.join(tmpDir, 'test.db');
15
+ });
16
+
17
+ afterEach(() => {
18
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
19
+ });
20
+
21
+ describe('bootstrapDb', () => {
22
+ it('creates files, symbols, symbols_fts, and meta tables', () => {
23
+ const db = new Database(dbPath);
24
+ bootstrapDb(db);
25
+ db.close();
26
+
27
+ // Re-open and check tables exist
28
+ const db2 = new Database(dbPath);
29
+ const tables = db2.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[];
30
+ const tableNames = tables.map(t => t.name);
31
+ expect(tableNames).toContain('files');
32
+ expect(tableNames).toContain('symbols');
33
+ expect(tableNames).toContain('meta');
34
+
35
+ // Check FTS virtual table (shows as 'table' type in sqlite_master)
36
+ const ftsTables = db2.prepare("SELECT name FROM sqlite_master WHERE name='symbols_fts'").all() as { name: string }[];
37
+ expect(ftsTables.length).toBe(1);
38
+ expect(ftsTables[0]!.name).toBe('symbols_fts');
39
+ db2.close();
40
+ });
41
+
42
+ it('is idempotent (can be called twice)', () => {
43
+ const db = new Database(dbPath);
44
+ bootstrapDb(db);
45
+ bootstrapDb(db);
46
+ db.close();
47
+ // No error = idempotent
48
+ });
49
+
50
+ it('sets user_version to 1', () => {
51
+ const db = new Database(dbPath);
52
+ bootstrapDb(db);
53
+ const version = db.pragma('user_version', { simple: true }) as number;
54
+ expect(version).toBe(1);
55
+ db.close();
56
+ });
57
+ });
58
+
59
+ describe('openDb', () => {
60
+ it('opens a DB and bootstraps it', () => {
61
+ const db = openDb(dbPath);
62
+ expect(db.open).toBe(true);
63
+ // Tables exist
64
+ const count = db.prepare("SELECT COUNT(*) as c FROM sqlite_master WHERE type='table'").get() as { c: number };
65
+ expect(count.c).toBeGreaterThan(0);
66
+ db.close();
67
+ });
68
+ });
69
+
70
+ describe('symbols CRUD', () => {
71
+ it('inserts a symbol and finds it via FTS MATCH', () => {
72
+ const db = openDb(dbPath);
73
+
74
+ upsertSymbol(db, {
75
+ name: 'myFunc',
76
+ kind: 'function',
77
+ file_path: 'src/test.ts',
78
+ line_start: 10,
79
+ line_end: 20,
80
+ signature: '(x: number) => string',
81
+ doc: 'Does something useful',
82
+ summary: '',
83
+ card_path: '/cards/myFunc.md',
84
+ });
85
+
86
+ // FTS search
87
+ const results = searchSymbols(db, 'myFunc', undefined, 10);
88
+ expect(results).toHaveLength(1);
89
+ expect(results[0]!.name).toBe('myFunc');
90
+ expect(results[0]!.kind).toBe('function');
91
+
92
+ db.close();
93
+ });
94
+
95
+ it('bm25 ranks name hit above doc-only hit', () => {
96
+ const db = openDb(dbPath);
97
+
98
+ // Doc hit: "token" only appears in doc text, not in name
99
+ upsertSymbol(db, {
100
+ name: 'loadData',
101
+ kind: 'function',
102
+ file_path: 'src/a.ts',
103
+ line_start: 1,
104
+ line_end: 5,
105
+ signature: '() => void',
106
+ doc: 'Helper to refresh the auth token',
107
+ summary: '',
108
+ card_path: '',
109
+ });
110
+
111
+ // Name hit: "token" is in the name
112
+ upsertSymbol(db, {
113
+ name: 'refreshToken',
114
+ kind: 'function',
115
+ file_path: 'src/b.ts',
116
+ line_start: 10,
117
+ line_end: 15,
118
+ signature: '() => Promise<string>',
119
+ doc: 'Gets a new token',
120
+ summary: '',
121
+ card_path: '',
122
+ });
123
+
124
+ const results = searchSymbols(db, 'token', undefined, 10);
125
+ expect(results.length).toBeGreaterThanOrEqual(2);
126
+ // First result should be the name hit (name is weighted higher in bm25)
127
+ expect(results[0]!.name).toBe('refreshToken');
128
+
129
+ db.close();
130
+ });
131
+
132
+ it('re-indexing a file is idempotent (no duplicate rows)', () => {
133
+ const db = openDb(dbPath);
134
+
135
+ // First insert
136
+ upsertSymbol(db, {
137
+ name: 'foo', kind: 'function', file_path: 'src/test.ts',
138
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
139
+ });
140
+
141
+ // Re-insert same file (simulate reindex)
142
+ deleteFileSymbols(db, 'src/test.ts');
143
+ upsertSymbol(db, {
144
+ name: 'foo', kind: 'function', file_path: 'src/test.ts',
145
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
146
+ });
147
+
148
+ const results = searchSymbols(db, 'foo', undefined, 10);
149
+ expect(results).toHaveLength(1);
150
+
151
+ db.close();
152
+ });
153
+
154
+ it('deleting a file removes only its symbols', () => {
155
+ const db = openDb(dbPath);
156
+
157
+ upsertSymbol(db, {
158
+ name: 'keep', kind: 'function', file_path: 'src/keep.ts',
159
+ line_start: 1, line_end: 1, signature: '', doc: '', summary: '', card_path: '',
160
+ });
161
+ upsertSymbol(db, {
162
+ name: 'remove', kind: 'function', file_path: 'src/remove.ts',
163
+ line_start: 2, line_end: 2, signature: '', doc: '', summary: '', card_path: '',
164
+ });
165
+
166
+ deleteFileSymbols(db, 'src/remove.ts');
167
+
168
+ const all = searchSymbols(db, '', undefined, 10);
169
+ expect(all).toHaveLength(1);
170
+ expect(all[0]!.name).toBe('keep');
171
+
172
+ db.close();
173
+ });
174
+ });
175
+
176
+ describe('meta', () => {
177
+ it('setMeta and getMeta round-trip values', () => {
178
+ const db = openDb(dbPath);
179
+ setMeta(db, 'last_indexed_commit', 'abc123');
180
+ setMeta(db, 'schema_version', '1');
181
+
182
+ expect(getMeta(db, 'last_indexed_commit')).toBe('abc123');
183
+ expect(getMeta(db, 'schema_version')).toBe('1');
184
+ expect(getMeta(db, 'nonexistent')).toBeNull();
185
+
186
+ db.close();
187
+ });
188
+
189
+ it('setMeta overwrites existing values', () => {
190
+ const db = openDb(dbPath);
191
+ setMeta(db, 'key', 'first');
192
+ setMeta(db, 'key', 'second');
193
+ expect(getMeta(db, 'key')).toBe('second');
194
+ db.close();
195
+ });
196
+ });
197
+ });