@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
@@ -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
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Extract leading doc comments from source code.
3
+ *
4
+ * Given a source file and the line number of a symbol, walk upward collecting
5
+ * adjacent comment lines. PURE module — no I/O.
6
+ */
7
+
8
+ /**
9
+ * Extract the doc comment block immediately above a given line in source.
10
+ * Returns the concatenated text of the comment(s), or "" if none.
11
+ */
12
+ export function extractDocComment(source: string, symbolLine: number): string {
13
+ if (symbolLine <= 1) return "";
14
+
15
+ const lines = source.split("\n");
16
+ const parts: string[] = [];
17
+
18
+ // Walk upward from the line just above the symbol
19
+ let i = symbolLine - 2; // 0-based
20
+ if (i < 0) return "";
21
+
22
+ while (i >= 0) {
23
+ const rawLine = lines[i] as string;
24
+ const trimmed = rawLine.trim();
25
+
26
+ // Blank line stops collection
27
+ if (!trimmed) break;
28
+
29
+ // Line comment (//)
30
+ if (trimmed.startsWith("//")) {
31
+ parts.unshift(trimmed.replace(/^\/\/\s*/, ""));
32
+ i--;
33
+ continue;
34
+ }
35
+
36
+ // Block comment handling
37
+ const isBlockOpen = trimmed.startsWith("/*") || trimmed.startsWith("/**");
38
+ const isBlockClose = trimmed.endsWith("*/");
39
+
40
+ // Block comment continuation line (starts with *)
41
+ // These appear between /* and */, typically "* text" or "*"
42
+ const isBlockContent = /^\*\s?/.test(trimmed) && !isBlockOpen && !isBlockClose;
43
+
44
+ if (isBlockClose) {
45
+ // Line contains `*/`
46
+ const content = extractStarContent(trimmed);
47
+ if (content) parts.unshift(content);
48
+ if (isBlockOpen) {
49
+ // Single line: /** ... */
50
+ i--;
51
+ continue;
52
+ }
53
+ // Multi-line: enter block content mode
54
+ // Continue walking up
55
+ i--;
56
+ while (i >= 0) {
57
+ const aboveTrimmed = (lines[i] as string).trim();
58
+ if (!aboveTrimmed) break;
59
+ if (aboveTrimmed.startsWith("//")) {
60
+ // Line comment ends the block, insert it and stop
61
+ parts.unshift(aboveTrimmed.replace(/^\/\/\s*/, ""));
62
+ i--;
63
+ continue;
64
+ }
65
+ if (aboveTrimmed.startsWith("/*") || aboveTrimmed.startsWith("/**")) {
66
+ // Found the opening
67
+ const openContent = extractStarContent(aboveTrimmed);
68
+ if (openContent) parts.unshift(openContent);
69
+ i--;
70
+ break;
71
+ }
72
+ // Block content line: * text or *
73
+ if (/^\*\s?/.test(aboveTrimmed)) {
74
+ const c = aboveTrimmed.replace(/^\*\s?/, "").trim();
75
+ if (c) parts.unshift(c);
76
+ i--;
77
+ continue;
78
+ }
79
+ // Not block content — stop
80
+ break;
81
+ }
82
+ continue;
83
+ }
84
+
85
+ if (isBlockContent) {
86
+ // Encountered a * continuation line without having seen */
87
+ // This means the */ is below us (closer to the symbol)
88
+ // Walk DOWN to find it, then walk back UP
89
+ // Actually, simpler: just treat * lines as content and walk up
90
+ const content = trimmed.replace(/^\*\s?/, "").trim();
91
+ if (content) parts.unshift(content);
92
+ i--;
93
+ continue;
94
+ }
95
+
96
+ if (isBlockOpen) {
97
+ // Found opening without a close marker — extract and continue
98
+ const content = extractStarContent(trimmed);
99
+ if (content) parts.unshift(content);
100
+ i--;
101
+ continue;
102
+ }
103
+
104
+ // Python docstring (""" or ''')
105
+ if (trimmed.startsWith('"""') || trimmed.startsWith("'''")) {
106
+ const quote = trimmed.startsWith('"""') ? '"""' : "'''";
107
+
108
+ // Single-line: """text"""
109
+ if (trimmed.startsWith(quote) && trimmed.endsWith(quote) && trimmed.length > quote.length) {
110
+ const inner = trimmed.slice(quote.length, -quote.length).trim();
111
+ if (inner) parts.unshift(inner);
112
+ i--;
113
+ continue;
114
+ }
115
+
116
+ // Multi-line opening
117
+ if (trimmed.startsWith(quote) && !trimmed.endsWith(quote)) {
118
+ const afterQuote = trimmed.slice(quote.length).trim();
119
+ if (afterQuote) parts.unshift(afterQuote);
120
+ i--;
121
+ // Walk up to find closing quote
122
+ while (i >= 0) {
123
+ const aboveTrimmed = (lines[i] as string).trim();
124
+ if (aboveTrimmed.endsWith(quote)) {
125
+ const beforeQuote = aboveTrimmed.slice(0, -quote.length).trim();
126
+ if (beforeQuote) parts.unshift(beforeQuote);
127
+ i--;
128
+ break;
129
+ }
130
+ parts.unshift(aboveTrimmed);
131
+ i--;
132
+ }
133
+ continue;
134
+ }
135
+
136
+ // Just """ on its own line (closing or opening a multi-line)
137
+ if (trimmed === quote) {
138
+ // This is the closing """ — walk up to find the opening
139
+ i--;
140
+ while (i >= 0) {
141
+ const aboveTrimmed = (lines[i] as string).trim();
142
+ if (aboveTrimmed.startsWith(quote)) {
143
+ const afterQuote = aboveTrimmed.slice(quote.length).trim();
144
+ if (afterQuote) parts.unshift(afterQuote);
145
+ i--;
146
+ break;
147
+ }
148
+ parts.unshift(aboveTrimmed);
149
+ i--;
150
+ }
151
+ continue;
152
+ }
153
+ }
154
+
155
+ // Not a comment — stop
156
+ break;
157
+ }
158
+
159
+ return parts.join("\n").trim();
160
+ }
161
+
162
+ /** Extract visible content from a line inside a /* or /** block comment. */
163
+ function extractStarContent(line: string): string {
164
+ return line
165
+ .replace(/^\/\**\s*/, "") // strip /* or /**
166
+ .replace(/\s*\*\/$/, "") // strip */
167
+ .replace(/^\*\s?/, "") // strip leading *
168
+ .trim();
169
+ }