@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/src/db.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * SQLite database layer for codewalker.
3
+ *
4
+ * Uses better-sqlite3 with WAL mode. The schema follows the design in the build prompt:
5
+ * - `files`: tracks indexed files (path, lang, sha, indexed_at)
6
+ * - `symbols`: one row per extracted symbol
7
+ * - `symbols_fts`: FTS5 virtual table for full-text search
8
+ * - `meta`: key/value store for index metadata
9
+ *
10
+ * FTS5 uses external-content mode pointing at `symbols`. When re-indexing a file,
11
+ * delete its rows + matching FTS rows, then INSERT fresh.
12
+ */
13
+
14
+ import Database, { type Database as DatabaseType } from "better-sqlite3";
15
+
16
+ export { Database };
17
+ export type { DatabaseType };
18
+
19
+ /** Open a DB file path, enable WAL, and bootstrap schema. */
20
+ export function openDb(dbPath: string): DatabaseType {
21
+ const db = new Database(dbPath);
22
+ db.pragma("journal_mode = WAL");
23
+ bootstrapDb(db);
24
+ return db;
25
+ }
26
+
27
+ /** Bootstrap DDL — idempotent (all CREATE use IF NOT EXISTS). */
28
+ export function bootstrapDb(db: DatabaseType): void {
29
+ db.exec(`
30
+ PRAGMA user_version = 1;
31
+
32
+ CREATE TABLE IF NOT EXISTS files (
33
+ path TEXT PRIMARY KEY,
34
+ lang TEXT,
35
+ blob_sha TEXT,
36
+ indexed_at TEXT
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS symbols (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ name TEXT NOT NULL,
42
+ kind TEXT,
43
+ file_path TEXT,
44
+ line_start INTEGER,
45
+ line_end INTEGER,
46
+ signature TEXT,
47
+ doc TEXT,
48
+ summary TEXT,
49
+ card_path TEXT
50
+ );
51
+
52
+ CREATE VIRTUAL TABLE IF NOT EXISTS symbols_fts USING fts5(
53
+ name, signature, doc, summary,
54
+ content='symbols', content_rowid='id',
55
+ tokenize='unicode61 remove_diacritics 2'
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
59
+ `);
60
+ }
61
+
62
+ /** Upsert a file record. */
63
+ export function upsertFile(
64
+ db: DatabaseType,
65
+ path: string,
66
+ lang: string,
67
+ blobSha: string,
68
+ ): void {
69
+ db.prepare(
70
+ `INSERT INTO files (path, lang, blob_sha, indexed_at)
71
+ VALUES (?, ?, ?, datetime('now'))
72
+ ON CONFLICT(path) DO UPDATE SET lang=excluded.lang, blob_sha=excluded.blob_sha, indexed_at=excluded.indexed_at`,
73
+ ).run(path, lang, blobSha);
74
+ }
75
+
76
+ /** Delete file tracking row. */
77
+ export function deleteFile(db: DatabaseType, filePath: string): void {
78
+ db.prepare("DELETE FROM files WHERE path = ?").run(filePath);
79
+ }
80
+
81
+ /** Insert or update a symbol. Replaces on (name, file_path) conflict. */
82
+ export function upsertSymbol(
83
+ db: DatabaseType,
84
+ symbol: {
85
+ name: string;
86
+ kind: string;
87
+ file_path: string;
88
+ line_start: number;
89
+ line_end: number;
90
+ signature: string;
91
+ doc: string;
92
+ summary: string;
93
+ card_path: string;
94
+ },
95
+ ): void {
96
+ // Delete existing FTS row for this row first (content=sync requires manual FTS management)
97
+ // We use a replace-or-insert approach: delete old, insert new
98
+ const existing = db.prepare(
99
+ "SELECT id FROM symbols WHERE name = ? AND file_path = ?",
100
+ ).get(symbol.name, symbol.file_path) as { id: number } | undefined;
101
+
102
+ if (existing) {
103
+ // FTS external content: must delete old content row
104
+ db.prepare("INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc, summary) VALUES ('delete', ?, '', '', '', '')").run(existing.id);
105
+ }
106
+
107
+ const result = db.prepare(
108
+ `INSERT INTO symbols (name, kind, file_path, line_start, line_end, signature, doc, summary, card_path)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
110
+ ON CONFLICT(id) DO UPDATE SET
111
+ kind=excluded.kind, line_start=excluded.line_start, line_end=excluded.line_end,
112
+ signature=excluded.signature, doc=excluded.doc, summary=excluded.summary, card_path=excluded.card_path`,
113
+ ).run(
114
+ symbol.name, symbol.kind, symbol.file_path, symbol.line_start, symbol.line_end,
115
+ symbol.signature, symbol.doc, symbol.summary, symbol.card_path,
116
+ );
117
+
118
+ // Insert into FTS
119
+ const rowId = existing?.id ?? (result.lastInsertRowid as number);
120
+ db.prepare(
121
+ `INSERT INTO symbols_fts(rowid, name, signature, doc, summary)
122
+ VALUES (?, ?, ?, ?, ?)`,
123
+ ).run(rowId, symbol.name, symbol.signature, symbol.doc, symbol.summary);
124
+ }
125
+
126
+ /** Delete all symbols for a given file. */
127
+ export function deleteFileSymbols(db: DatabaseType, filePath: string): void {
128
+ const rows = db.prepare("SELECT id FROM symbols WHERE file_path = ?").all(filePath) as { id: number }[];
129
+ for (const row of rows) {
130
+ db.prepare("INSERT INTO symbols_fts(symbols_fts, rowid, name, signature, doc, summary) VALUES ('delete', ?, '', '', '', '')").run(row.id);
131
+ }
132
+ db.prepare("DELETE FROM symbols WHERE file_path = ?").run(filePath);
133
+ }
134
+
135
+ /** Search symbols via FTS5 MATCH, ranked by bm25. */
136
+ export function searchSymbols(
137
+ db: DatabaseType,
138
+ query: string,
139
+ kindFilter?: string,
140
+ limit = 10,
141
+ ): Array<{
142
+ id: number;
143
+ name: string;
144
+ kind: string;
145
+ file_path: string;
146
+ line_start: number;
147
+ line_end: number;
148
+ signature: string;
149
+ summary: string;
150
+ score: number;
151
+ }> {
152
+ if (!query.trim()) {
153
+ // Return all symbols ordered by name
154
+ let sql = "SELECT s.id, s.name, s.kind, s.file_path, s.line_start, s.line_end, s.signature, s.summary, 0.0 as score FROM symbols s";
155
+ const params: unknown[] = [];
156
+ if (kindFilter) {
157
+ sql += " WHERE s.kind = ?";
158
+ params.push(kindFilter);
159
+ }
160
+ sql += " ORDER BY s.name LIMIT ?";
161
+ params.push(limit);
162
+ return db.prepare(sql).all(...params) as typeof results;
163
+ }
164
+
165
+ let sql = `
166
+ SELECT s.id, s.name, s.kind, s.file_path, s.line_start, s.line_end, s.signature, s.summary,
167
+ bm25(symbols_fts, 10.0, 5.0, 1.0, 8.0) as score
168
+ FROM symbols_fts
169
+ JOIN symbols s ON s.id = symbols_fts.rowid
170
+ WHERE symbols_fts MATCH ?
171
+ `;
172
+ const params: unknown[] = [query];
173
+
174
+ if (kindFilter) {
175
+ sql += " AND s.kind = ?";
176
+ params.push(kindFilter);
177
+ }
178
+
179
+ sql += " ORDER BY score LIMIT ?";
180
+ params.push(limit);
181
+
182
+ return db.prepare(sql).all(...params) as typeof results;
183
+ }
184
+
185
+ /** Get a meta value. */
186
+ export function getMeta(db: DatabaseType, key: string): string | null {
187
+ const row = db.prepare("SELECT value FROM meta WHERE key = ?").get(key) as { value: string } | undefined;
188
+ return row?.value ?? null;
189
+ }
190
+
191
+ /** Set a meta value. */
192
+ export function setMeta(db: DatabaseType, key: string, value: string): void {
193
+ db.prepare(
194
+ "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
195
+ ).run(key, value);
196
+ }
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseCtagsLine, parseCtagsOutput, mapCtagsKind, type CtagsTag } from './ctags-parse.ts';
3
+
4
+ describe('mapCtagsKind', () => {
5
+ it('maps ctags kind to SymbolKind', () => {
6
+ expect(mapCtagsKind('function')).toBe('function');
7
+ expect(mapCtagsKind('variable')).toBe('const');
8
+ expect(mapCtagsKind('class')).toBe('class');
9
+ expect(mapCtagsKind('member')).toBe('method');
10
+ expect(mapCtagsKind('enum')).toBe('enum');
11
+ expect(mapCtagsKind('typedef')).toBe('type');
12
+ expect(mapCtagsKind('interface')).toBe('interface');
13
+ expect(mapCtagsKind('namespace')).toBe('namespace');
14
+ expect(mapCtagsKind('module')).toBe('module');
15
+ });
16
+
17
+ it('returns the kind as-is for unknown kinds', () => {
18
+ expect(mapCtagsKind('macro')).toBe('macro');
19
+ expect(mapCtagsKind('unknown')).toBe('unknown');
20
+ });
21
+ });
22
+
23
+ describe('parseCtagsLine', () => {
24
+ it('parses a valid ctags JSON line into a CtagsTag', () => {
25
+ const line = '{"_type":"tag","name":"myFunction","path":"src/file.ts","pattern":"/^export function myFunction()/","line":10,"kind":"function","signature":"(param: string)"}';
26
+ const result = parseCtagsLine(line);
27
+ expect(result).not.toBeNull();
28
+ expect(result!.name).toBe('myFunction');
29
+ expect(result!.path).toBe('src/file.ts');
30
+ expect(result!.line).toBe(10);
31
+ expect(result!.kind).toBe('function');
32
+ expect(result!.signature).toBe('(param: string)');
33
+ });
34
+
35
+ it('returns null for non-tag lines', () => {
36
+ expect(parseCtagsLine('{"_type":"other","name":"foo"}')).toBeNull();
37
+ expect(parseCtagsLine('not json')).toBeNull();
38
+ expect(parseCtagsLine('')).toBeNull();
39
+ });
40
+
41
+ it('tolerates missing signature field', () => {
42
+ const line = '{"_type":"tag","name":"foo","path":"a.ts","line":5,"kind":"function"}';
43
+ const result = parseCtagsLine(line);
44
+ expect(result).not.toBeNull();
45
+ expect(result!.signature).toBe('');
46
+ });
47
+
48
+ it('tolerates missing kind field', () => {
49
+ const line = '{"_type":"tag","name":"foo","path":"a.ts","line":5}';
50
+ const result = parseCtagsLine(line);
51
+ expect(result).not.toBeNull();
52
+ expect(result!.kind).toBe('unknown');
53
+ });
54
+
55
+ it('handles numeric kind field (ctags numeric kind)', () => {
56
+ const line = '{"_type":"tag","name":"Foo","path":"a.ts","line":10,"kind":"class"}';
57
+ const result = parseCtagsLine(line);
58
+ expect(result).not.toBeNull();
59
+ expect(result!.kind).toBe('class');
60
+ });
61
+ });
62
+
63
+ describe('parseCtagsOutput', () => {
64
+ it('parses multi-line ctags JSON output into Symbol array', () => {
65
+ const output = [
66
+ '{"_type":"tag","name":"myFunc","path":"src/a.ts","line":5,"kind":"function","signature":"()"}',
67
+ '{"_type":"tag","name":"MyClass","path":"src/a.ts","line":20,"kind":"class","signature":""}',
68
+ '{"_type":"tag","name":"MY_CONST","path":"src/b.ts","line":1,"kind":"variable","signature":"42"}',
69
+ ].join('\n');
70
+
71
+ const symbols = parseCtagsOutput(output, '/root/project');
72
+ expect(symbols).toHaveLength(3);
73
+
74
+ expect(symbols[0]!.name).toBe('myFunc');
75
+ expect(symbols[0]!.kind).toBe('function');
76
+ expect(symbols[0]!.file_path).toBe('/root/project/src/a.ts');
77
+ expect(symbols[0]!.line_start).toBe(5);
78
+
79
+ expect(symbols[1]!.name).toBe('MyClass');
80
+ expect(symbols[1]!.kind).toBe('class');
81
+
82
+ expect(symbols[2]!.name).toBe('MY_CONST');
83
+ expect(symbols[2]!.kind).toBe('const');
84
+ });
85
+
86
+ it('skips malformed and non-tag lines', () => {
87
+ const output = [
88
+ '{"_type":"tag","name":"valid","path":"a.ts","line":1,"kind":"function","signature":""}',
89
+ 'not json at all',
90
+ '{"_type":"notag","name":"skip"}',
91
+ '',
92
+ '{"_type":"tag","name":"valid2","path":"a.ts","line":5,"kind":"class","signature":""}',
93
+ ].join('\n');
94
+
95
+ const symbols = parseCtagsOutput(output, '/root');
96
+ expect(symbols).toHaveLength(2);
97
+ });
98
+
99
+ it('returns empty array for empty output', () => {
100
+ expect(parseCtagsOutput('', '/root')).toEqual([]);
101
+ });
102
+
103
+ it('resolves relative paths against project root', () => {
104
+ const output = '{"_type":"tag","name":"f","path":"src/lib/util.ts","line":3,"kind":"function","signature":""}';
105
+ const symbols = parseCtagsOutput(output, '/home/user/project');
106
+ expect(symbols[0]!.file_path).toBe('/home/user/project/src/lib/util.ts');
107
+ });
108
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Parse Universal Ctags JSON output into Symbol[].
3
+ *
4
+ * Universal Ctags emits one JSON object per line with `--output-format=json`.
5
+ * Fields: _type, name, path, pattern, line, kind, signature.
6
+ *
7
+ * This module is PURE — no I/O, takes strings and returns objects.
8
+ */
9
+
10
+ import type { Symbol, SymbolKind } from "../types.ts";
11
+
12
+ /** Raw ctags tag as parsed from a JSON line. */
13
+ export interface CtagsTag {
14
+ name: string;
15
+ path: string;
16
+ line: number;
17
+ kind: string;
18
+ signature: string;
19
+ }
20
+
21
+ /**
22
+ * Map a ctags `kind` string to our canonical SymbolKind.
23
+ * Unknown kinds are returned as-is so callers can handle them or skip.
24
+ */
25
+ export function mapCtagsKind(kind: string): string {
26
+ const mapping: Record<string, string> = {
27
+ function: "function",
28
+ variable: "const",
29
+ class: "class",
30
+ member: "method",
31
+ enum: "enum",
32
+ typedef: "type",
33
+ interface: "interface",
34
+ namespace: "namespace",
35
+ module: "module",
36
+ };
37
+ return mapping[kind] ?? kind;
38
+ }
39
+
40
+ /**
41
+ * Parse a single ctags JSON line into a CtagsTag, or null if it's not a tag line.
42
+ */
43
+ export function parseCtagsLine(line: string): CtagsTag | null {
44
+ const trimmed = line.trim();
45
+ if (!trimmed) return null;
46
+
47
+ let parsed: Record<string, unknown>;
48
+ try {
49
+ parsed = JSON.parse(trimmed) as Record<string, unknown>;
50
+ } catch {
51
+ return null;
52
+ }
53
+
54
+ // Only process _type === "tag"
55
+ if (parsed["_type"] !== "tag") return null;
56
+
57
+ const name = parsed["name"];
58
+ const path = parsed["path"];
59
+ const lineNum = parsed["line"];
60
+ const kind = parsed["kind"];
61
+ const signature = parsed["signature"];
62
+
63
+ if (typeof name !== "string" || typeof path !== "string") return null;
64
+ if (typeof lineNum !== "number") return null;
65
+
66
+ return {
67
+ name,
68
+ path,
69
+ line: lineNum,
70
+ kind: typeof kind === "string" ? kind : "unknown",
71
+ signature: typeof signature === "string" ? signature : "",
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Parse multi-line ctags JSON output, returning Symbol[] with absolute paths.
77
+ *
78
+ * @param output - The raw stdout from ctags (one JSON object per line).
79
+ * @param projectRoot - Absolute path to the project root, used to resolve relative file paths.
80
+ */
81
+ export function parseCtagsOutput(output: string, projectRoot: string): Symbol[] {
82
+ if (!output.trim()) return [];
83
+
84
+ const lines = output.split("\n");
85
+ const symbols: Symbol[] = [];
86
+
87
+ for (const line of lines) {
88
+ const tag = parseCtagsLine(line);
89
+ if (!tag) continue;
90
+
91
+ // Resolve relative paths against project root
92
+ const filePath = tag.path.startsWith("/")
93
+ ? tag.path
94
+ : `${projectRoot.replace(/\/+$/, "")}/${tag.path}`;
95
+
96
+ symbols.push({
97
+ name: tag.name,
98
+ kind: mapCtagsKind(tag.kind) as SymbolKind,
99
+ file_path: filePath,
100
+ line_start: tag.line,
101
+ line_end: tag.line, // ctags only gives start line
102
+ signature: tag.signature,
103
+ doc: "",
104
+ summary: "",
105
+ card_path: "",
106
+ });
107
+ }
108
+
109
+ return symbols;
110
+ }
111
+
112
+
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Thin shell: detect ctags on PATH and run it.
3
+ *
4
+ * Separated from ctags-parse.ts (PURE parsing) so the I/O boundary is explicit.
5
+ */
6
+
7
+ import { execSync } from "node:child_process";
8
+
9
+ /**
10
+ * Detect whether ctags is available on PATH.
11
+ */
12
+ export function detectCtags(): boolean {
13
+ try {
14
+ execSync("ctags --version", { stdio: "ignore", timeout: 5000 });
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Run ctags on a list of files and return raw JSON output.
23
+ */
24
+ export function runCtags(files: string[], projectRoot: string): string {
25
+ const fileList = files.join(" ");
26
+ return execSync(
27
+ `ctags --output-format=json --fields=+nKzS -f - ${fileList}`,
28
+ {
29
+ cwd: projectRoot,
30
+ encoding: "utf-8",
31
+ stdio: ["ignore", "pipe", "pipe"],
32
+ timeout: 30000,
33
+ maxBuffer: 10 * 1024 * 1024,
34
+ },
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Run ctags on a single file and return raw JSON output.
40
+ */
41
+ export function runCtagsOnFile(filePath: string, projectRoot: string): string {
42
+ return execSync(
43
+ `ctags --output-format=json --fields=+nKzS -f - "${filePath}"`,
44
+ {
45
+ cwd: projectRoot,
46
+ encoding: "utf-8",
47
+ stdio: ["ignore", "pipe", "pipe"],
48
+ timeout: 10000,
49
+ },
50
+ );
51
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { extractDocComment } from './docs.ts';
3
+
4
+ describe('extractDocComment', () => {
5
+ it('extracts a JSDoc block comment above a given line', () => {
6
+ const source = `/**
7
+ * This is a doc comment for myFunc.
8
+ * It has multiple lines.
9
+ */
10
+ function myFunc() {}
11
+ `;
12
+ const doc = extractDocComment(source, 4);
13
+ expect(doc).toContain('This is a doc comment for myFunc');
14
+ expect(doc).toContain('It has multiple lines');
15
+ });
16
+
17
+ it('extracts // line comment block above a given line', () => {
18
+ const source = `// This is a line comment
19
+ // that spans two lines
20
+ function myFunc() {}
21
+ `;
22
+ const doc = extractDocComment(source, 3);
23
+ expect(doc).toContain('This is a line comment');
24
+ expect(doc).toContain('that spans two lines');
25
+ });
26
+
27
+ it('extracts a Python docstring above a given line', () => {
28
+ const source = `"""This is a Python docstring
29
+ for my function.
30
+ """
31
+ def my_func():
32
+ pass
33
+ `;
34
+ const doc = extractDocComment(source, 4);
35
+ expect(doc).toContain('This is a Python docstring');
36
+ expect(doc).toContain('for my function.');
37
+ });
38
+
39
+ it('returns empty string when there is no doc comment', () => {
40
+ const source = `const x = 1;
41
+ function myFunc() {}
42
+ `;
43
+ const doc = extractDocComment(source, 2);
44
+ expect(doc).toBe('');
45
+ });
46
+
47
+ it('returns empty string for a symbol on the first line with no preceding comments', () => {
48
+ const source = `function first() {}`;
49
+ const doc = extractDocComment(source, 1);
50
+ expect(doc).toBe('');
51
+ });
52
+
53
+ it('extracts mixed // and /* */ comment lines', () => {
54
+ const source = `// A brief explanation
55
+ /* Then a block comment */
56
+ function mixed() {}
57
+ `;
58
+ const doc = extractDocComment(source, 3);
59
+ expect(doc).toContain('A brief explanation');
60
+ expect(doc).toContain('Then a block comment');
61
+ });
62
+
63
+ it('stops at blank lines when collecting adjacent comments', () => {
64
+ const source = `// Not adjacent
65
+
66
+ // Directly above
67
+ function myFunc() {}
68
+ `;
69
+ const doc = extractDocComment(source, 4);
70
+ expect(doc).toContain('Directly above');
71
+ expect(doc).not.toContain('Not adjacent');
72
+ });
73
+
74
+ it('handles single-line Python docstrings', () => {
75
+ const source = `"""Single line docstring."""
76
+ def quick(): pass
77
+ `;
78
+ const doc = extractDocComment(source, 2);
79
+ expect(doc).toContain('Single line docstring.');
80
+ });
81
+ });