@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 +44 -50
- package/index.ts +6 -42
- package/package.json +20 -39
- package/prompts/codewalker.md +7 -0
- package/skills/codewalker/SKILL.md +43 -0
- package/src/cards.test.ts +88 -0
- package/src/cards.ts +87 -0
- package/src/db.test.ts +197 -0
- package/src/db.ts +196 -0
- package/src/extract/ctags-parse.test.ts +108 -0
- package/src/extract/ctags-parse.ts +112 -0
- package/src/extract/ctags.ts +51 -0
- package/src/extract/docs.test.ts +81 -0
- package/src/extract/docs.ts +169 -0
- package/src/extract/regex.test.ts +202 -0
- package/src/extract/regex.ts +192 -0
- package/src/format.test.ts +71 -0
- package/src/format.ts +63 -0
- package/src/git.test.ts +75 -0
- package/src/git.ts +62 -0
- package/src/index.contract.test.ts +100 -0
- package/src/index.ts +124 -0
- package/src/indexer.test.ts +138 -0
- package/src/indexer.ts +352 -0
- package/src/project.test.ts +115 -0
- package/src/project.ts +204 -0
- package/src/query.test.ts +98 -0
- package/src/query.ts +73 -0
- package/src/sync.test.ts +116 -0
- package/src/types.ts +89 -0
- package/vitest.config.ts +28 -0
- package/LICENSE +0 -21
- package/agents.ts +0 -126
- package/compat.ts +0 -217
- package/detect.ts +0 -188
- package/docs/PRD.md +0 -78
- package/prd.ts +0 -106
- package/skills/learn-this/SKILL.md +0 -325
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractRegex, extractTsJs, extractPython, extractGo } from './regex.ts';
|
|
3
|
+
|
|
4
|
+
describe('extractTsJs', () => {
|
|
5
|
+
it('extracts function declarations', () => {
|
|
6
|
+
const source = `
|
|
7
|
+
function hello() {
|
|
8
|
+
return 1;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function greet(name: string) {
|
|
12
|
+
return "hi";
|
|
13
|
+
}
|
|
14
|
+
`;
|
|
15
|
+
const symbols = extractTsJs(source, 'src/test.ts');
|
|
16
|
+
expect(symbols).toHaveLength(2);
|
|
17
|
+
expect(symbols[0]!.name).toBe('hello');
|
|
18
|
+
expect(symbols[0]!.kind).toBe('function');
|
|
19
|
+
expect(symbols[0]!.line_start).toBe(2);
|
|
20
|
+
expect(symbols[1]!.name).toBe('greet');
|
|
21
|
+
expect(symbols[1]!.line_start).toBe(6);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('extracts export const (not plain const)', () => {
|
|
25
|
+
const source = `
|
|
26
|
+
export const PI = 3.14;
|
|
27
|
+
export const getName = () => "hello";
|
|
28
|
+
const local = "secret";
|
|
29
|
+
`;
|
|
30
|
+
const symbols = extractTsJs(source, 'src/consts.ts');
|
|
31
|
+
expect(symbols).toHaveLength(2);
|
|
32
|
+
expect(symbols[0]!.name).toBe('PI');
|
|
33
|
+
expect(symbols[0]!.kind).toBe('const');
|
|
34
|
+
expect(symbols[1]!.name).toBe('getName');
|
|
35
|
+
expect(symbols[1]!.kind).toBe('const');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('extracts class declarations', () => {
|
|
39
|
+
const source = `
|
|
40
|
+
class MyClass {
|
|
41
|
+
method() {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class ExportedClass {
|
|
45
|
+
foo() {}
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
const symbols = extractTsJs(source, 'src/classes.ts');
|
|
49
|
+
expect(symbols).toHaveLength(2);
|
|
50
|
+
expect(symbols[0]!.name).toBe('MyClass');
|
|
51
|
+
expect(symbols[0]!.kind).toBe('class');
|
|
52
|
+
expect(symbols[1]!.name).toBe('ExportedClass');
|
|
53
|
+
expect(symbols[1]!.kind).toBe('class');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('extracts type and interface declarations', () => {
|
|
57
|
+
const source = `
|
|
58
|
+
type UserId = string;
|
|
59
|
+
interface User {
|
|
60
|
+
name: string;
|
|
61
|
+
}
|
|
62
|
+
export type Status = "active" | "inactive";
|
|
63
|
+
`;
|
|
64
|
+
const symbols = extractTsJs(source, 'src/types.ts');
|
|
65
|
+
expect(symbols).toHaveLength(3);
|
|
66
|
+
expect(symbols[0]!.name).toBe('UserId');
|
|
67
|
+
expect(symbols[0]!.kind).toBe('type');
|
|
68
|
+
expect(symbols[1]!.name).toBe('User');
|
|
69
|
+
expect(symbols[1]!.kind).toBe('interface');
|
|
70
|
+
expect(symbols[2]!.name).toBe('Status');
|
|
71
|
+
expect(symbols[2]!.kind).toBe('type');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('extracts async functions and generators', () => {
|
|
75
|
+
const source = `
|
|
76
|
+
async function fetchData() {}
|
|
77
|
+
function* generate() {}
|
|
78
|
+
async function* stream() {}
|
|
79
|
+
`;
|
|
80
|
+
const symbols = extractTsJs(source, 'src/async.ts');
|
|
81
|
+
expect(symbols).toHaveLength(3);
|
|
82
|
+
expect(symbols[0]!.name).toBe('fetchData');
|
|
83
|
+
expect(symbols[1]!.name).toBe('generate');
|
|
84
|
+
expect(symbols[2]!.name).toBe('stream');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('extracts export function', () => {
|
|
88
|
+
const source = 'export function doSomething(arg: string): void {}';
|
|
89
|
+
const symbols = extractTsJs(source, 'src/exports.ts');
|
|
90
|
+
expect(symbols).toHaveLength(1);
|
|
91
|
+
expect(symbols[0]!.name).toBe('doSomething');
|
|
92
|
+
expect(symbols[0]!.kind).toBe('function');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('extractPython', () => {
|
|
97
|
+
it('extracts function definitions', () => {
|
|
98
|
+
const source = `
|
|
99
|
+
def hello():
|
|
100
|
+
return 1
|
|
101
|
+
|
|
102
|
+
def greet(name):
|
|
103
|
+
return "hi"
|
|
104
|
+
`;
|
|
105
|
+
const symbols = extractPython(source, 'src/test.py');
|
|
106
|
+
expect(symbols).toHaveLength(2);
|
|
107
|
+
expect(symbols[0]!.name).toBe('hello');
|
|
108
|
+
expect(symbols[0]!.kind).toBe('function');
|
|
109
|
+
expect(symbols[0]!.line_start).toBe(2);
|
|
110
|
+
expect(symbols[1]!.name).toBe('greet');
|
|
111
|
+
expect(symbols[1]!.line_start).toBe(5);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('extracts class definitions and methods', () => {
|
|
115
|
+
const source = `
|
|
116
|
+
class MyClass:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
class AnotherClass:
|
|
120
|
+
def method(self):
|
|
121
|
+
pass
|
|
122
|
+
`;
|
|
123
|
+
const symbols = extractPython(source, 'src/classes.py');
|
|
124
|
+
expect(symbols).toHaveLength(3);
|
|
125
|
+
expect(symbols[0]!.name).toBe('MyClass');
|
|
126
|
+
expect(symbols[0]!.kind).toBe('class');
|
|
127
|
+
expect(symbols[1]!.name).toBe('AnotherClass');
|
|
128
|
+
expect(symbols[1]!.kind).toBe('class');
|
|
129
|
+
expect(symbols[2]!.name).toBe('method');
|
|
130
|
+
expect(symbols[2]!.kind).toBe('function');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('extracts async def', () => {
|
|
134
|
+
const source = 'async def fetch_data(): pass';
|
|
135
|
+
const symbols = extractPython(source, 'src/async.py');
|
|
136
|
+
expect(symbols).toHaveLength(1);
|
|
137
|
+
expect(symbols[0]!.name).toBe('fetch_data');
|
|
138
|
+
expect(symbols[0]!.kind).toBe('function');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('extractGo', () => {
|
|
143
|
+
it('extracts function declarations', () => {
|
|
144
|
+
const source = `
|
|
145
|
+
func hello() {}
|
|
146
|
+
|
|
147
|
+
func greet(name string) string {
|
|
148
|
+
return name
|
|
149
|
+
}
|
|
150
|
+
`;
|
|
151
|
+
const symbols = extractGo(source, 'src/main.go');
|
|
152
|
+
expect(symbols).toHaveLength(2);
|
|
153
|
+
expect(symbols[0]!.name).toBe('hello');
|
|
154
|
+
expect(symbols[0]!.kind).toBe('function');
|
|
155
|
+
expect(symbols[0]!.line_start).toBe(2);
|
|
156
|
+
expect(symbols[1]!.name).toBe('greet');
|
|
157
|
+
expect(symbols[1]!.line_start).toBe(4);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('extracts methods with receivers', () => {
|
|
161
|
+
const source = `
|
|
162
|
+
func (u *User) GetName() string {
|
|
163
|
+
return u.name
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func (s *Service) Serve() error {
|
|
167
|
+
return nil
|
|
168
|
+
}
|
|
169
|
+
`;
|
|
170
|
+
const symbols = extractGo(source, 'src/methods.go');
|
|
171
|
+
expect(symbols).toHaveLength(2);
|
|
172
|
+
expect(symbols[0]!.name).toBe('GetName');
|
|
173
|
+
expect(symbols[0]!.kind).toBe('method');
|
|
174
|
+
expect(symbols[1]!.name).toBe('Serve');
|
|
175
|
+
expect(symbols[1]!.kind).toBe('method');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('extractRegex', () => {
|
|
180
|
+
it('dispatches to the correct language extractor for .ts', () => {
|
|
181
|
+
const symbols = extractRegex('function foo() {}', 'src/test.ts');
|
|
182
|
+
expect(symbols).toHaveLength(1);
|
|
183
|
+
expect(symbols[0]!.name).toBe('foo');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('dispatches to the correct language extractor for .py', () => {
|
|
187
|
+
const symbols = extractRegex('def foo(): pass', 'src/test.py');
|
|
188
|
+
expect(symbols).toHaveLength(1);
|
|
189
|
+
expect(symbols[0]!.name).toBe('foo');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('dispatches to the correct language extractor for .go', () => {
|
|
193
|
+
const symbols = extractRegex('func foo() {}', 'src/test.go');
|
|
194
|
+
expect(symbols).toHaveLength(1);
|
|
195
|
+
expect(symbols[0]!.name).toBe('foo');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns empty array for unsupported file extensions', () => {
|
|
199
|
+
const symbols = extractRegex('fn foo() {}', 'src/test.rs');
|
|
200
|
+
expect(symbols).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex-based fallback symbol extraction for TS/JS/Py/Go.
|
|
3
|
+
*
|
|
4
|
+
* Used when ctags is not available on PATH. PURE module — no I/O.
|
|
5
|
+
*
|
|
6
|
+
* This is best-effort: it uses line-oriented regex patterns and does a simple
|
|
7
|
+
* comment/string skip that handles most common cases but is not an AST parser.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Symbol, SymbolKind } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
/** Extract symbols from TypeScript/JavaScript source. */
|
|
13
|
+
export function extractTsJs(source: string, filePath: string): Symbol[] {
|
|
14
|
+
const lines = source.split("\n");
|
|
15
|
+
const symbols: Symbol[] = [];
|
|
16
|
+
|
|
17
|
+
// Track multi-line comments
|
|
18
|
+
let inBlockComment = false;
|
|
19
|
+
|
|
20
|
+
// Patterns for TS/JS declarations
|
|
21
|
+
const patterns: Array<{ regex: RegExp; kind: SymbolKind }> = [
|
|
22
|
+
{ regex: /^(?:export\s+)?(?:async\s+)?function\s+(?:<[^>]+>\s+)?(\w+)/, kind: "function" },
|
|
23
|
+
{ regex: /^(?:export\s+)?(?:async\s+)?function\s*\*?\s*(\w+)/, kind: "function" },
|
|
24
|
+
{ regex: /^(?:export\s+)?class\s+(\w+)/, kind: "class" },
|
|
25
|
+
{ regex: /^(?:export\s+)?interface\s+(\w+)/, kind: "interface" },
|
|
26
|
+
{ regex: /^(?:export\s+)?type\s+(\w+)\s*=/, kind: "type" },
|
|
27
|
+
{ regex: /^export\s+(?:const|let|var)\s+(\w+)\s*(?::\s*[^=]+)?\s*=/, kind: "const" },
|
|
28
|
+
{ regex: /^export\s+(?:const|let|var)\s+(\w+)\s*(?::\s*[^;{]+)?;/, kind: "const" },
|
|
29
|
+
{ regex: /^(?:export\s+)?enum\s+(\w+)/, kind: "enum" },
|
|
30
|
+
{ regex: /^(?:export\s+)?abstract\s+class\s+(\w+)/, kind: "class" },
|
|
31
|
+
{ regex: /^(?:export\s+)?default\s+(?:async\s+)?function\s+(\w+)/, kind: "function" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < lines.length; i++) {
|
|
35
|
+
const line = lines[i] as string;
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
|
|
38
|
+
// Skip empty lines
|
|
39
|
+
if (!trimmed) continue;
|
|
40
|
+
|
|
41
|
+
// Track block comments
|
|
42
|
+
if (inBlockComment) {
|
|
43
|
+
if (trimmed.includes("*/")) {
|
|
44
|
+
inBlockComment = false;
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (trimmed.startsWith("/*") || trimmed.startsWith("/**")) {
|
|
49
|
+
if (trimmed.includes("*/") && !trimmed.startsWith("*/")) {
|
|
50
|
+
// Single-line block comment, skip
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!trimmed.includes("*/")) {
|
|
54
|
+
inBlockComment = true;
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Skip single-line comments
|
|
60
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("#")) continue;
|
|
61
|
+
|
|
62
|
+
// Apply patterns
|
|
63
|
+
for (const { regex, kind } of patterns) {
|
|
64
|
+
const match = trimmed.match(regex);
|
|
65
|
+
if (match && match[1]) {
|
|
66
|
+
symbols.push({
|
|
67
|
+
name: match[1],
|
|
68
|
+
kind,
|
|
69
|
+
file_path: filePath,
|
|
70
|
+
line_start: i + 1, // 1-based
|
|
71
|
+
line_end: i + 1,
|
|
72
|
+
signature: "",
|
|
73
|
+
doc: "",
|
|
74
|
+
summary: "",
|
|
75
|
+
card_path: "",
|
|
76
|
+
});
|
|
77
|
+
break; // one symbol per line
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return symbols;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Extract symbols from Python source. */
|
|
86
|
+
export function extractPython(source: string, filePath: string): Symbol[] {
|
|
87
|
+
const lines = source.split("\n");
|
|
88
|
+
const symbols: Symbol[] = [];
|
|
89
|
+
|
|
90
|
+
const patterns: Array<{ regex: RegExp; kind: SymbolKind }> = [
|
|
91
|
+
{ regex: /^(?:async\s+)?def\s+(\w+)\s*\(/, kind: "function" },
|
|
92
|
+
{ regex: /^class\s+(\w+)/, kind: "class" },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
for (let i = 0; i < lines.length; i++) {
|
|
96
|
+
const trimmed = (lines[i] as string).trim();
|
|
97
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
98
|
+
|
|
99
|
+
for (const { regex, kind } of patterns) {
|
|
100
|
+
const match = trimmed.match(regex);
|
|
101
|
+
if (match && match[1]) {
|
|
102
|
+
symbols.push({
|
|
103
|
+
name: match[1],
|
|
104
|
+
kind,
|
|
105
|
+
file_path: filePath,
|
|
106
|
+
line_start: i + 1,
|
|
107
|
+
line_end: i + 1,
|
|
108
|
+
signature: "",
|
|
109
|
+
doc: "",
|
|
110
|
+
summary: "",
|
|
111
|
+
card_path: "",
|
|
112
|
+
});
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return symbols;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Extract symbols from Go source. */
|
|
122
|
+
export function extractGo(source: string, filePath: string): Symbol[] {
|
|
123
|
+
const lines = source.split("\n");
|
|
124
|
+
const symbols: Symbol[] = [];
|
|
125
|
+
|
|
126
|
+
const funcPattern = /^func\s+(?:\([^)]*\)\s+)?(\w+)\s*\(/;
|
|
127
|
+
const methodPattern = /^func\s+\([^)]*\)\s+(\w+)\s*\(/;
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < lines.length; i++) {
|
|
130
|
+
const trimmed = (lines[i] as string).trim();
|
|
131
|
+
if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("/*")) continue;
|
|
132
|
+
|
|
133
|
+
// Check for method first (has receiver)
|
|
134
|
+
const methodMatch = trimmed.match(methodPattern);
|
|
135
|
+
if (methodMatch && methodMatch[1]) {
|
|
136
|
+
symbols.push({
|
|
137
|
+
name: methodMatch[1],
|
|
138
|
+
kind: "method",
|
|
139
|
+
file_path: filePath,
|
|
140
|
+
line_start: i + 1,
|
|
141
|
+
line_end: i + 1,
|
|
142
|
+
signature: "",
|
|
143
|
+
doc: "",
|
|
144
|
+
summary: "",
|
|
145
|
+
card_path: "",
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Then check for regular function
|
|
151
|
+
const funcMatch = trimmed.match(funcPattern);
|
|
152
|
+
if (funcMatch && funcMatch[1]) {
|
|
153
|
+
symbols.push({
|
|
154
|
+
name: funcMatch[1],
|
|
155
|
+
kind: "function",
|
|
156
|
+
file_path: filePath,
|
|
157
|
+
line_start: i + 1,
|
|
158
|
+
line_end: i + 1,
|
|
159
|
+
signature: "",
|
|
160
|
+
doc: "",
|
|
161
|
+
summary: "",
|
|
162
|
+
card_path: "",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return symbols;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Dispatch to the correct language extractor based on file extension.
|
|
172
|
+
* Returns an empty array for unsupported languages.
|
|
173
|
+
*/
|
|
174
|
+
export function extractRegex(source: string, filePath: string): Symbol[] {
|
|
175
|
+
const ext = filePath.toLowerCase().split(".").pop() ?? "";
|
|
176
|
+
|
|
177
|
+
switch (ext) {
|
|
178
|
+
case "ts":
|
|
179
|
+
case "tsx":
|
|
180
|
+
case "js":
|
|
181
|
+
case "jsx":
|
|
182
|
+
case "mjs":
|
|
183
|
+
case "cjs":
|
|
184
|
+
return extractTsJs(source, filePath);
|
|
185
|
+
case "py":
|
|
186
|
+
return extractPython(source, filePath);
|
|
187
|
+
case "go":
|
|
188
|
+
return extractGo(source, filePath);
|
|
189
|
+
default:
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { formatCompact, formatCardBody } from './format.ts';
|
|
3
|
+
import type { QueryResultRow, StalenessInfo } from './types.ts';
|
|
4
|
+
|
|
5
|
+
function makeRow(overrides: Partial<QueryResultRow> = {}): QueryResultRow {
|
|
6
|
+
return {
|
|
7
|
+
id: 1,
|
|
8
|
+
name: 'myFunc',
|
|
9
|
+
kind: 'function',
|
|
10
|
+
file_path: 'src/util/helper.ts',
|
|
11
|
+
line_start: 10,
|
|
12
|
+
line_end: 20,
|
|
13
|
+
signature: '(x: number) => string',
|
|
14
|
+
summary: 'Does something useful with the input',
|
|
15
|
+
score: 0.5,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('formatCompact', () => {
|
|
21
|
+
it('formats N rows into N compact lines', () => {
|
|
22
|
+
const rows = [makeRow({ name: 'foo' }), makeRow({ name: 'bar', kind: 'class' })];
|
|
23
|
+
const result = formatCompact(rows, null);
|
|
24
|
+
expect(result).toContain('foo');
|
|
25
|
+
expect(result).toContain('bar');
|
|
26
|
+
expect(result).toContain('function');
|
|
27
|
+
expect(result).toContain('class');
|
|
28
|
+
expect(result).toContain('helper.ts:10-20');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('truncates long summaries', () => {
|
|
32
|
+
const longSummary = 'A'.repeat(200);
|
|
33
|
+
const rows = [makeRow({ summary: longSummary })];
|
|
34
|
+
const result = formatCompact(rows, null);
|
|
35
|
+
// Summary should be truncated
|
|
36
|
+
expect(result.length).toBeLessThan(400);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('outputs capped at limit', () => {
|
|
40
|
+
const rows = Array.from({ length: 20 }, (_, i) => makeRow({ name: `func${i}` }));
|
|
41
|
+
const result = formatCompact(rows.slice(0, 5), null);
|
|
42
|
+
expect(result).toContain('func0');
|
|
43
|
+
expect(result).not.toContain('func10');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns a friendly message for empty rows', () => {
|
|
47
|
+
const result = formatCompact([], null);
|
|
48
|
+
expect(result).toContain('No matches');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('appends staleness note when present', () => {
|
|
52
|
+
const staleness: StalenessInfo = {
|
|
53
|
+
indexedCommit: 'abc123',
|
|
54
|
+
headCommit: 'def456',
|
|
55
|
+
changedFiles: 3,
|
|
56
|
+
message: 'index stale',
|
|
57
|
+
};
|
|
58
|
+
const rows = [makeRow()];
|
|
59
|
+
const result = formatCompact(rows, staleness);
|
|
60
|
+
expect(result).toContain('index stale');
|
|
61
|
+
expect(result).toContain('abc123');
|
|
62
|
+
expect(result).toContain('def456');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('formatCardBody', () => {
|
|
67
|
+
it('returns the card body text', () => {
|
|
68
|
+
const body = '# myFunc\n\nDoes something.\n';
|
|
69
|
+
expect(formatCardBody(body)).toContain('Does something.');
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact result formatter for codewalker queries.
|
|
3
|
+
*
|
|
4
|
+
* PURE module — no I/O. Produces token-bounded, one-line-per-hit output.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { QueryResultRow, StalenessInfo } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const SUMMARY_MAX = 60;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Format query results into compact, token-efficient output.
|
|
13
|
+
*
|
|
14
|
+
* Each row becomes one line: `name · kind · file:line · summary`
|
|
15
|
+
* The total output is bounded by the number of rows.
|
|
16
|
+
*/
|
|
17
|
+
export function formatCompact(
|
|
18
|
+
rows: QueryResultRow[],
|
|
19
|
+
staleness: StalenessInfo | null,
|
|
20
|
+
): string {
|
|
21
|
+
if (rows.length === 0) {
|
|
22
|
+
let msg = "No matches found.";
|
|
23
|
+
if (staleness) {
|
|
24
|
+
msg += ` (${staleness.message})`;
|
|
25
|
+
}
|
|
26
|
+
return msg;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const lines = rows.map((row) => {
|
|
30
|
+
const loc = `${basename(row.file_path)}:${row.line_start}-${row.line_end}`;
|
|
31
|
+
const summary = truncate(row.summary || "", SUMMARY_MAX);
|
|
32
|
+
return `${row.name} · ${row.kind} · ${loc} · ${summary}`;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (staleness) {
|
|
36
|
+
lines.push(
|
|
37
|
+
`---\n⚠ ${staleness.message}: indexed @${shortSha(staleness.indexedCommit)}, HEAD @${shortSha(staleness.headCommit)} (${staleness.changedFiles} file(s) changed)`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Format a card body for display.
|
|
46
|
+
*/
|
|
47
|
+
export function formatCardBody(body: string): string {
|
|
48
|
+
return body.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function basename(filePath: string): string {
|
|
52
|
+
const idx = filePath.lastIndexOf("/");
|
|
53
|
+
return idx >= 0 ? filePath.slice(idx + 1) : filePath;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function truncate(s: string, max: number): string {
|
|
57
|
+
if (s.length <= max) return s;
|
|
58
|
+
return s.slice(0, max - 1) + "…";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function shortSha(sha: string): string {
|
|
62
|
+
return sha.slice(0, 7);
|
|
63
|
+
}
|