@aprimediet/codewalker 1.0.0 → 1.2.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.
Files changed (46) hide show
  1. package/README.md +44 -50
  2. package/index.ts +6 -42
  3. package/package.json +20 -39
  4. package/prompts/codewalker.md +7 -0
  5. package/skills/codewalker/SKILL.md +43 -0
  6. package/src/cards.test.ts +88 -0
  7. package/src/cards.ts +87 -0
  8. package/src/db.test.ts +343 -0
  9. package/src/db.ts +363 -0
  10. package/src/extract/ctags-parse.test.ts +108 -0
  11. package/src/extract/ctags-parse.ts +112 -0
  12. package/src/extract/ctags.ts +51 -0
  13. package/src/extract/docs.test.ts +81 -0
  14. package/src/extract/docs.ts +169 -0
  15. package/src/extract/regex.test.ts +202 -0
  16. package/src/extract/regex.ts +192 -0
  17. package/src/format.test.ts +123 -0
  18. package/src/format.ts +69 -0
  19. package/src/git.test.ts +75 -0
  20. package/src/git.ts +62 -0
  21. package/src/index.contract.test.ts +145 -0
  22. package/src/index.ts +173 -0
  23. package/src/indexer.test.ts +138 -0
  24. package/src/indexer.ts +352 -0
  25. package/src/libs/cards.test.ts +86 -0
  26. package/src/libs/cards.ts +53 -0
  27. package/src/libs/dts.test.ts +269 -0
  28. package/src/libs/dts.ts +213 -0
  29. package/src/libs/indexer.test.ts +236 -0
  30. package/src/libs/indexer.ts +291 -0
  31. package/src/libs/resolve.test.ts +218 -0
  32. package/src/libs/resolve.ts +120 -0
  33. package/src/project.test.ts +115 -0
  34. package/src/project.ts +206 -0
  35. package/src/query.test.ts +169 -0
  36. package/src/query.ts +89 -0
  37. package/src/sync.test.ts +116 -0
  38. package/src/types.ts +117 -0
  39. package/vitest.config.ts +28 -0
  40. package/LICENSE +0 -21
  41. package/agents.ts +0 -126
  42. package/compat.ts +0 -217
  43. package/detect.ts +0 -188
  44. package/docs/PRD.md +0 -78
  45. package/prd.ts +0 -106
  46. package/skills/learn-this/SKILL.md +0 -325
package/src/indexer.ts ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Codebase indexer: full scan and git-anchored incremental sync.
3
+ *
4
+ * - `scan()`: full build — walks the project tree, extracts symbols, writes cards, populates DB.
5
+ * - `sync()`: git-anchored incremental — reindexes only changed files.
6
+ * - `rebuildDbFromCards()`: rebuilds the DB from markdown cards alone (disposable-index property).
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import { openDb, upsertSymbol, deleteFileSymbols, deleteFile, setMeta, getMeta } from "./db.ts";
12
+ import { detectCtags, runCtags, runCtagsOnFile } from "./extract/ctags.ts";
13
+ import { parseCtagsOutput } from "./extract/ctags-parse.ts";
14
+ import { extractRegex } from "./extract/regex.ts";
15
+ import { extractDocComment } from "./extract/docs.ts";
16
+ import { renderCard, parseCard } from "./cards.ts";
17
+ import { getHeadSha, changedFilesSince } from "./git.ts";
18
+ import type { Symbol } from "./types.ts";
19
+
20
+ export interface ScanOptions {
21
+ projectRoot: string;
22
+ globalCodewalkerDir: string;
23
+ dbPath: string;
24
+ entriesDir: string;
25
+ symbolsDir: string;
26
+ useCtags?: boolean;
27
+ }
28
+
29
+ // Supported file extensions for extraction
30
+ const SUPPORTED_EXTENSIONS = new Set([
31
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
32
+ ".py", ".go",
33
+ ]);
34
+
35
+ /**
36
+ * Full scan: walk the project tree, extract symbols, write cards, populate DB.
37
+ * Idempotent: re-running rebuilds everything from scratch.
38
+ */
39
+ export async function scan(options: ScanOptions): Promise<void> {
40
+ const { projectRoot, dbPath, entriesDir, symbolsDir, globalCodewalkerDir } = options;
41
+ const useCtags = options.useCtags ?? detectCtags();
42
+
43
+ // Ensure directories exist
44
+ fs.mkdirSync(symbolsDir, { recursive: true });
45
+
46
+ // Collect all source files
47
+ const files = collectSourceFiles(projectRoot);
48
+
49
+ // Extract symbols
50
+ const allSymbols: Symbol[] = [];
51
+
52
+ if (useCtags) {
53
+ const ctagsSymbols = runCtagsWrapper(projectRoot, files);
54
+ allSymbols.push(...ctagsSymbols);
55
+ }
56
+
57
+ // Regex fallback for files ctags might have missed or when ctags is absent
58
+ const regexFiles = useCtags
59
+ ? files.filter(f => !hasCtagsSupport(path.extname(f)))
60
+ : files;
61
+
62
+ for (const filePath of regexFiles) {
63
+ const ext = path.extname(filePath).toLowerCase();
64
+ if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
65
+
66
+ const source = fs.readFileSync(filePath, "utf-8");
67
+ const symbols = extractRegex(source, filePath);
68
+ allSymbols.push(...symbols);
69
+ }
70
+
71
+ // For all symbols, extract doc comments
72
+ for (const sym of allSymbols) {
73
+ try {
74
+ const source = fs.readFileSync(sym.file_path, "utf-8");
75
+ const doc = extractDocComment(source, sym.line_start);
76
+ sym.doc = doc;
77
+ } catch {
78
+ // File might have been deleted between scan and read
79
+ }
80
+ }
81
+
82
+ // Open DB
83
+ const db = openDb(dbPath);
84
+
85
+ try {
86
+ // Start transaction
87
+ db.exec("BEGIN TRANSACTION");
88
+
89
+ // Clear existing data for this project's files
90
+ // We track which files we're about to index
91
+ const indexedPaths = new Set(allSymbols.map(s => s.file_path));
92
+
93
+ // Remove existing entries for files that no longer exist
94
+ const existingFiles = db.prepare("SELECT path FROM files").all() as { path: string }[];
95
+ for (const f of existingFiles) {
96
+ if (!indexedPaths.has(f.path)) {
97
+ deleteFileSymbols(db, f.path);
98
+ deleteFile(db, f.path);
99
+ }
100
+ }
101
+
102
+ // For existing files, also clean and re-insert
103
+ for (const sym of allSymbols) {
104
+ deleteFileSymbols(db, sym.file_path);
105
+ }
106
+
107
+ // Write cards and insert symbols
108
+ for (const sym of allSymbols) {
109
+ // Generate card path
110
+ const fileSlug = slugFromPath(sym.file_path);
111
+ const cardFileName = `${sanitizeName(sym.name)}.md`;
112
+ const cardDir = path.join(symbolsDir, fileSlug);
113
+ fs.mkdirSync(cardDir, { recursive: true });
114
+ const cardPath = path.join(cardDir, cardFileName);
115
+
116
+ // Render and write card
117
+ const card = renderCard(sym);
118
+ const tmpPath = cardPath + ".tmp";
119
+ fs.writeFileSync(tmpPath, card, { encoding: "utf-8", mode: 0o600 });
120
+ fs.renameSync(tmpPath, cardPath);
121
+
122
+ // Update sym with card_path and insert into DB
123
+ sym.card_path = cardPath;
124
+ upsertSymbol(db, sym);
125
+ }
126
+
127
+ // Update meta
128
+ const headSha = getHeadSha(projectRoot) ?? "";
129
+ setMeta(db, "last_indexed_commit", headSha);
130
+ setMeta(db, "last_full_scan", new Date().toISOString());
131
+ setMeta(db, "schema_version", "1");
132
+
133
+ db.exec("COMMIT");
134
+ } catch (e) {
135
+ db.exec("ROLLBACK");
136
+ throw e;
137
+ } finally {
138
+ db.close();
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Git-anchored incremental sync.
144
+ * Reindexes only changed files since last_indexed_commit.
145
+ */
146
+ export async function sync(options: ScanOptions): Promise<void> {
147
+ const { projectRoot, dbPath, entriesDir, symbolsDir } = options;
148
+ const useCtags = options.useCtags ?? detectCtags();
149
+
150
+ const db = openDb(dbPath);
151
+
152
+ try {
153
+ const lastCommit = getMeta(db, "last_indexed_commit");
154
+ let changedFiles: string[] = [];
155
+
156
+ if (lastCommit) {
157
+ changedFiles = changedFilesSince(projectRoot, lastCommit);
158
+ }
159
+
160
+ // Convert git's relative paths to absolute
161
+ const changedAbs = changedFiles.map((f) => path.resolve(projectRoot, f));
162
+
163
+ // Also scan for new files (git might not track untracked files)
164
+ const allFiles = collectSourceFiles(projectRoot);
165
+ const indexedFiles = new Set(
166
+ (db.prepare("SELECT path FROM files").all() as { path: string[] }).map(r => r.path as string),
167
+ );
168
+
169
+ // Find new files not yet indexed
170
+ const newFiles = allFiles.filter(f => !indexedFiles.has(f));
171
+
172
+ // Combine changed + new (all absolute paths)
173
+ const filesToProcess = new Set([...changedAbs, ...newFiles]);
174
+
175
+ if (filesToProcess.size === 0) {
176
+ // Nothing to do, but still update commit pointer
177
+ const headSha = getHeadSha(projectRoot) ?? "";
178
+ setMeta(db, "last_indexed_commit", headSha);
179
+ db.close();
180
+ return;
181
+ }
182
+
183
+ // Process changed files
184
+ for (const fullPath of filesToProcess) {
185
+ const ext = path.extname(fullPath).toLowerCase();
186
+
187
+ if (!fs.existsSync(fullPath)) {
188
+ // File was deleted — use absolute path for DB operations
189
+ deleteFileSymbols(db, fullPath);
190
+ deleteFile(db, fullPath);
191
+
192
+ // Remove card directory
193
+ const fileSlug = slugFromPath(fullPath);
194
+ const cardDir = path.join(symbolsDir, fileSlug);
195
+ if (fs.existsSync(cardDir)) {
196
+ fs.rmSync(cardDir, { recursive: true, force: true });
197
+ }
198
+ continue;
199
+ }
200
+
201
+ if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
202
+
203
+ // Re-extract
204
+ const source = fs.readFileSync(fullPath, "utf-8");
205
+ let symbols: Symbol[] = [];
206
+
207
+ if (useCtags && hasCtagsSupport(ext)) {
208
+ const ctagsOutput = runCtagsOnFile(fullPath, projectRoot);
209
+ symbols = parseCtagsOutput(ctagsOutput, projectRoot);
210
+ } else if (SUPPORTED_EXTENSIONS.has(ext)) {
211
+ symbols = extractRegex(source, fullPath);
212
+ }
213
+
214
+ // Extract doc comments
215
+ for (const sym of symbols) {
216
+ sym.doc = extractDocComment(source, sym.line_start);
217
+ }
218
+
219
+ // Remove old entries — use the full absolute path
220
+ deleteFileSymbols(db, fullPath);
221
+
222
+ // Write cards and insert
223
+ for (const sym of symbols) {
224
+ const fileSlug = slugFromPath(sym.file_path);
225
+ const cardFileName = `${sanitizeName(sym.name)}.md`;
226
+ const cardDir = path.join(symbolsDir, fileSlug);
227
+ fs.mkdirSync(cardDir, { recursive: true });
228
+ const cardPath = path.join(cardDir, cardFileName);
229
+
230
+ const card = renderCard(sym);
231
+ const tmpPath = cardPath + ".tmp";
232
+ fs.writeFileSync(tmpPath, card, { encoding: "utf-8", mode: 0o600 });
233
+ fs.renameSync(tmpPath, cardPath);
234
+
235
+ sym.card_path = cardPath;
236
+ upsertSymbol(db, sym);
237
+ }
238
+ }
239
+
240
+ // Update commit pointer
241
+ const headSha = getHeadSha(projectRoot) ?? "";
242
+ setMeta(db, "last_indexed_commit", headSha);
243
+
244
+ db.close();
245
+ } catch (e) {
246
+ db.close();
247
+ throw e;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Rebuild the SQLite DB from markdown cards alone.
253
+ * This demonstrates the disposable-index property: cards are the source of truth.
254
+ */
255
+ export function rebuildDbFromCards(
256
+ dbPath: string,
257
+ entriesDir: string,
258
+ ): void {
259
+ const symbolsDir = path.join(entriesDir, "symbols");
260
+ if (!fs.existsSync(symbolsDir)) return;
261
+
262
+ const db = openDb(dbPath);
263
+
264
+ try {
265
+ db.exec("BEGIN TRANSACTION");
266
+
267
+ // Walk card files
268
+ const walkDir = (dir: string): void => {
269
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
270
+ const fullPath = path.join(dir, entry.name);
271
+ if (entry.isDirectory()) {
272
+ walkDir(fullPath);
273
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
274
+ const card = fs.readFileSync(fullPath, "utf-8");
275
+ const parsed = parseCard(card);
276
+ if (!parsed) continue;
277
+
278
+ const { head } = parsed;
279
+ const locMatch = head.location.match(/^(.+):(\d+)-(\d+)$/);
280
+ if (!locMatch) continue;
281
+
282
+ upsertSymbol(db, {
283
+ name: head.name,
284
+ kind: head.kind,
285
+ file_path: locMatch[1] ?? head.location,
286
+ line_start: parseInt(locMatch[2] ?? "0", 10),
287
+ line_end: parseInt(locMatch[3] ?? "0", 10),
288
+ signature: head.signature,
289
+ doc: parsed.body,
290
+ summary: head.summary,
291
+ card_path: fullPath,
292
+ });
293
+ }
294
+ }
295
+ };
296
+
297
+ walkDir(symbolsDir);
298
+ db.exec("COMMIT");
299
+ } catch (e) {
300
+ db.exec("ROLLBACK");
301
+ throw e;
302
+ } finally {
303
+ db.close();
304
+ }
305
+ }
306
+
307
+ // ---- Internal helpers ----
308
+
309
+ function collectSourceFiles(rootDir: string): string[] {
310
+ const files: string[] = [];
311
+ const walk = (dir: string) => {
312
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
313
+ const fullPath = path.join(dir, entry.name);
314
+ if (entry.isDirectory()) {
315
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".pi" || entry.name.startsWith(".")) continue;
316
+ walk(fullPath);
317
+ } else if (entry.isFile()) {
318
+ const ext = path.extname(entry.name).toLowerCase();
319
+ if (SUPPORTED_EXTENSIONS.has(ext)) {
320
+ files.push(fullPath);
321
+ }
322
+ }
323
+ }
324
+ };
325
+ walk(rootDir);
326
+ return files;
327
+ }
328
+
329
+ function hasCtagsSupport(ext: string): boolean {
330
+ return [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go"].includes(ext);
331
+ }
332
+
333
+ function runCtagsWrapper(projectRoot: string, files: string[]): Symbol[] {
334
+ try {
335
+ const output = runCtags(files, projectRoot);
336
+ return parseCtagsOutput(output, projectRoot);
337
+ } catch {
338
+ return [];
339
+ }
340
+ }
341
+
342
+ function slugFromPath(filePath: string): string {
343
+ return filePath
344
+ .replace(/^\/+/, "")
345
+ .replace(/[^a-zA-Z0-9_\-/]/g, "_")
346
+ .replace(/\//g, "-")
347
+ .toLowerCase();
348
+ }
349
+
350
+ function sanitizeName(name: string): string {
351
+ return name.replace(/[^a-zA-Z0-9_$]/g, "_");
352
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tests for libs/cards.ts — lib symbol card rendering.
3
+ *
4
+ * PURE unit tests — no I/O.
5
+ */
6
+
7
+ import { describe, it, expect } from "vitest";
8
+ import { renderLibCard } from "./cards.ts";
9
+ import { parseCard } from "../cards.ts";
10
+ import type { LibSymbol } from "../types.ts";
11
+
12
+ function makeLibSymbol(overrides: Partial<LibSymbol> = {}): LibSymbol {
13
+ return {
14
+ lib: "hono",
15
+ version: "4.6.3",
16
+ name: "createMiddleware",
17
+ kind: "function",
18
+ signature: "export declare function createMiddleware<E>(handler: Hono): MiddlewareHandler",
19
+ doc: "Define a typed middleware handler.\nUse this to wrap Hono handlers with middleware support.",
20
+ summary: "Define a typed middleware handler.",
21
+ card_path: "",
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe("renderLibCard", () => {
27
+ it("includes lib and version in the frontmatter head", () => {
28
+ const sym = makeLibSymbol();
29
+ const card = renderLibCard(sym);
30
+ expect(card).toContain("lib: hono");
31
+ expect(card).toContain("version: 4.6.3");
32
+ });
33
+
34
+ it("includes name, kind, signature, summary in head", () => {
35
+ const sym = makeLibSymbol();
36
+ const card = renderLibCard(sym);
37
+ expect(card).toContain("name: createMiddleware");
38
+ expect(card).toContain("kind: function");
39
+ expect(card).toContain("signature:");
40
+ expect(card).toContain("summary:");
41
+ });
42
+
43
+ it("has a markdown body with the symbol name as heading", () => {
44
+ const sym = makeLibSymbol();
45
+ const card = renderLibCard(sym);
46
+ expect(card).toContain("# createMiddleware");
47
+ });
48
+
49
+ it("includes doc/body text after the frontmatter", () => {
50
+ const sym = makeLibSymbol();
51
+ const card = renderLibCard(sym);
52
+ expect(card).toContain("Use this to wrap Hono handlers");
53
+ });
54
+
55
+ it("round-trips via parseCard (head fields are preserved)", () => {
56
+ const sym = makeLibSymbol();
57
+ const card = renderLibCard(sym);
58
+ const parsed = parseCard(card);
59
+ expect(parsed).not.toBeNull();
60
+ expect(parsed!.head.name).toBe("createMiddleware");
61
+ expect(parsed!.head.kind).toBe("function");
62
+ expect(parsed!.head.summary).toContain("Define a typed middleware handler");
63
+ });
64
+
65
+ it("produces a module-kind card for README-only packages (no signature)", () => {
66
+ const sym = makeLibSymbol({
67
+ name: "express",
68
+ kind: "module",
69
+ signature: "",
70
+ doc: "Express web framework.",
71
+ summary: "Express web framework.",
72
+ });
73
+ const card = renderLibCard(sym);
74
+ expect(card).toContain("name: express");
75
+ expect(card).toContain("kind: module");
76
+ expect(card).toContain("# express");
77
+ expect(card).toContain("Express web framework");
78
+ });
79
+
80
+ it("handles empty doc gracefully", () => {
81
+ const sym = makeLibSymbol({ doc: "", summary: "" });
82
+ const card = renderLibCard(sym);
83
+ expect(card).toContain("name: createMiddleware");
84
+ expect(card).not.toContain("undefined");
85
+ });
86
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Lib symbol card rendering for codewalker v1.2.
3
+ *
4
+ * PURE module — no I/O. Renders a LibSymbol into a markdown card
5
+ * with frontmatter head (includes lib and version) and JSDoc body.
6
+ */
7
+
8
+ import type { LibSymbol } from "../types.ts";
9
+ import * as path from "node:path";
10
+
11
+ /**
12
+ * Render a LibSymbol into a markdown card string.
13
+ *
14
+ * Frontmatter head includes v1.1 fields PLUS lib/version:
15
+ * ---
16
+ * name: createMiddleware
17
+ * kind: function
18
+ * lib: hono
19
+ * version: 4.6.3
20
+ * signature: export declare function ...
21
+ * location: hono/dist/helper.d.ts (dts_path if available)
22
+ * summary: ...
23
+ * ---
24
+ */
25
+ export function renderLibCard(sym: LibSymbol): string {
26
+ const lines: string[] = ["---"];
27
+
28
+ addHeadField(lines, "name", sym.name);
29
+ addHeadField(lines, "kind", sym.kind);
30
+ addHeadField(lines, "lib", sym.lib);
31
+ addHeadField(lines, "version", sym.version);
32
+ if (sym.signature) addHeadField(lines, "signature", sym.signature);
33
+ if (sym.summary) addHeadField(lines, "summary", sym.summary);
34
+
35
+ lines.push("---");
36
+ lines.push("");
37
+
38
+ // Body
39
+ lines.push(`# ${sym.name}`);
40
+ lines.push("");
41
+
42
+ if (sym.doc) {
43
+ lines.push(sym.doc);
44
+ }
45
+
46
+ return lines.join("\n") + "\n";
47
+ }
48
+
49
+ function addHeadField(lines: string[], key: string, value: string): void {
50
+ // Ensure value doesn't break frontmatter
51
+ const safe = value.replace(/\n/g, " ").trim();
52
+ lines.push(`${key}: ${safe}`);
53
+ }