@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.
- 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 +343 -0
- package/src/db.ts +363 -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 +123 -0
- package/src/format.ts +69 -0
- package/src/git.test.ts +75 -0
- package/src/git.ts +62 -0
- package/src/index.contract.test.ts +145 -0
- package/src/index.ts +173 -0
- package/src/indexer.test.ts +138 -0
- package/src/indexer.ts +352 -0
- package/src/libs/cards.test.ts +86 -0
- package/src/libs/cards.ts +53 -0
- package/src/libs/dts.test.ts +269 -0
- package/src/libs/dts.ts +213 -0
- package/src/libs/indexer.test.ts +236 -0
- package/src/libs/indexer.ts +291 -0
- package/src/libs/resolve.test.ts +218 -0
- package/src/libs/resolve.ts +120 -0
- package/src/project.test.ts +115 -0
- package/src/project.ts +206 -0
- package/src/query.test.ts +169 -0
- package/src/query.ts +89 -0
- package/src/sync.test.ts +116 -0
- package/src/types.ts +117 -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,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,123 @@
|
|
|
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 makeLibRow(overrides: Partial<QueryResultRow> = {}): QueryResultRow {
|
|
6
|
+
return {
|
|
7
|
+
id: 100,
|
|
8
|
+
name: 'createMiddleware',
|
|
9
|
+
kind: 'function',
|
|
10
|
+
file_path: 'hono/dist/helper.d.ts',
|
|
11
|
+
line_start: 0,
|
|
12
|
+
line_end: 0,
|
|
13
|
+
signature: 'export declare function createMiddleware<E>(...): MiddlewareHandler',
|
|
14
|
+
summary: 'Define a typed middleware handler.',
|
|
15
|
+
score: 0.3,
|
|
16
|
+
source: 'lib',
|
|
17
|
+
lib: 'hono',
|
|
18
|
+
version: '4.6.3',
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function makeRow(overrides: Partial<QueryResultRow> = {}): QueryResultRow {
|
|
24
|
+
return {
|
|
25
|
+
id: 1,
|
|
26
|
+
name: 'myFunc',
|
|
27
|
+
kind: 'function',
|
|
28
|
+
file_path: 'src/util/helper.ts',
|
|
29
|
+
line_start: 10,
|
|
30
|
+
line_end: 20,
|
|
31
|
+
signature: '(x: number) => string',
|
|
32
|
+
summary: 'Does something useful with the input',
|
|
33
|
+
score: 0.5,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('formatCompact', () => {
|
|
39
|
+
it('formats N rows into N compact lines', () => {
|
|
40
|
+
const rows = [makeRow({ name: 'foo' }), makeRow({ name: 'bar', kind: 'class' })];
|
|
41
|
+
const result = formatCompact(rows, null);
|
|
42
|
+
expect(result).toContain('foo');
|
|
43
|
+
expect(result).toContain('bar');
|
|
44
|
+
expect(result).toContain('function');
|
|
45
|
+
expect(result).toContain('class');
|
|
46
|
+
expect(result).toContain('helper.ts:10-20');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('truncates long summaries', () => {
|
|
50
|
+
const longSummary = 'A'.repeat(200);
|
|
51
|
+
const rows = [makeRow({ summary: longSummary })];
|
|
52
|
+
const result = formatCompact(rows, null);
|
|
53
|
+
// Summary should be truncated
|
|
54
|
+
expect(result.length).toBeLessThan(400);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('outputs capped at limit', () => {
|
|
58
|
+
const rows = Array.from({ length: 20 }, (_, i) => makeRow({ name: `func${i}` }));
|
|
59
|
+
const result = formatCompact(rows.slice(0, 5), null);
|
|
60
|
+
expect(result).toContain('func0');
|
|
61
|
+
expect(result).not.toContain('func10');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns a friendly message for empty rows', () => {
|
|
65
|
+
const result = formatCompact([], null);
|
|
66
|
+
expect(result).toContain('No matches');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('appends staleness note when present', () => {
|
|
70
|
+
const staleness: StalenessInfo = {
|
|
71
|
+
indexedCommit: 'abc123',
|
|
72
|
+
headCommit: 'def456',
|
|
73
|
+
changedFiles: 3,
|
|
74
|
+
message: 'index stale',
|
|
75
|
+
};
|
|
76
|
+
const rows = [makeRow()];
|
|
77
|
+
const result = formatCompact(rows, staleness);
|
|
78
|
+
expect(result).toContain('index stale');
|
|
79
|
+
expect(result).toContain('abc123');
|
|
80
|
+
expect(result).toContain('def456');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('formatCompact with lib rows', () => {
|
|
85
|
+
it('renders a lib row with [lib@version] origin tag', () => {
|
|
86
|
+
const rows = [makeLibRow()];
|
|
87
|
+
const result = formatCompact(rows, null);
|
|
88
|
+
expect(result).toContain('createMiddleware');
|
|
89
|
+
expect(result).toContain('function');
|
|
90
|
+
expect(result).toContain('[hono@4.6.3]');
|
|
91
|
+
expect(result).toContain('Define a typed middleware handler.');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('renders mixed code and lib rows', () => {
|
|
95
|
+
const libRow = makeLibRow();
|
|
96
|
+
const codeRow: QueryResultRow = {
|
|
97
|
+
id: 1, name: 'myFunc', kind: 'function',
|
|
98
|
+
file_path: 'src/util/helper.ts', line_start: 10, line_end: 20,
|
|
99
|
+
signature: '(x: number) => string', summary: 'Does something', score: 0.5,
|
|
100
|
+
};
|
|
101
|
+
const result = formatCompact([codeRow, libRow], null);
|
|
102
|
+
expect(result).toContain('helper.ts:10-20');
|
|
103
|
+
expect(result).toContain('[hono@4.6.3]');
|
|
104
|
+
// Two lines
|
|
105
|
+
expect(result.split('\n')).toHaveLength(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('bounded output for lib rows (one line per hit)', () => {
|
|
109
|
+
const rows = Array.from({ length: 5 }, (_, i) =>
|
|
110
|
+
makeLibRow({ name: `fn${i}`, lib: 'test-pkg', version: '1.0.0' })
|
|
111
|
+
);
|
|
112
|
+
const result = formatCompact(rows, null);
|
|
113
|
+
expect(result.split('\n')).toHaveLength(5);
|
|
114
|
+
expect(result).toContain('[test-pkg@1.0.0]');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('formatCardBody', () => {
|
|
119
|
+
it('returns the card body text', () => {
|
|
120
|
+
const body = '# myFunc\n\nDoes something.\n';
|
|
121
|
+
expect(formatCardBody(body)).toContain('Does something.');
|
|
122
|
+
});
|
|
123
|
+
});
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
if (row.source === "lib" && row.lib && row.version) {
|
|
31
|
+
const origin = `[${row.lib}@${row.version}]`;
|
|
32
|
+
const summary = truncate(row.summary || "", SUMMARY_MAX);
|
|
33
|
+
const loc = row.file_path ? `${basename(row.file_path)}:${row.line_start}-${row.line_end}` : `lib`;
|
|
34
|
+
return `${row.name} · ${row.kind} · ${origin} · ${loc} · ${summary}`;
|
|
35
|
+
}
|
|
36
|
+
const loc = `${basename(row.file_path)}:${row.line_start}-${row.line_end}`;
|
|
37
|
+
const summary = truncate(row.summary || "", SUMMARY_MAX);
|
|
38
|
+
return `${row.name} · ${row.kind} · ${loc} · ${summary}`;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (staleness) {
|
|
42
|
+
lines.push(
|
|
43
|
+
`---\n⚠ ${staleness.message}: indexed @${shortSha(staleness.indexedCommit)}, HEAD @${shortSha(staleness.headCommit)} (${staleness.changedFiles} file(s) changed)`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format a card body for display.
|
|
52
|
+
*/
|
|
53
|
+
export function formatCardBody(body: string): string {
|
|
54
|
+
return body.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function basename(filePath: string): string {
|
|
58
|
+
const idx = filePath.lastIndexOf("/");
|
|
59
|
+
return idx >= 0 ? filePath.slice(idx + 1) : filePath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function truncate(s: string, max: number): string {
|
|
63
|
+
if (s.length <= max) return s;
|
|
64
|
+
return s.slice(0, max - 1) + "…";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function shortSha(sha: string): string {
|
|
68
|
+
return sha.slice(0, 7);
|
|
69
|
+
}
|
package/src/git.test.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
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 { execSync } from 'node:child_process';
|
|
6
|
+
import { getHeadSha, changedFilesSince, parseDiffNameOnly } from './git.ts';
|
|
7
|
+
|
|
8
|
+
describe('parseDiffNameOnly', () => {
|
|
9
|
+
it('parses git diff --name-only output into string[]', () => {
|
|
10
|
+
const output = `src/a.ts\nsrc/b.ts\nREADME.md\n`;
|
|
11
|
+
const files = parseDiffNameOnly(output);
|
|
12
|
+
expect(files).toEqual(['src/a.ts', 'src/b.ts', 'README.md']);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns empty array for empty output', () => {
|
|
16
|
+
expect(parseDiffNameOnly('')).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('trims whitespace from each line', () => {
|
|
20
|
+
const output = ` src/a.ts \n src/b.ts\n`;
|
|
21
|
+
const files = parseDiffNameOnly(output);
|
|
22
|
+
expect(files).toEqual(['src/a.ts', 'src/b.ts']);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('git operations in a real repo', () => {
|
|
27
|
+
let tmpDir: string;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-git-'));
|
|
31
|
+
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
32
|
+
execSync('git config user.email test@test.com', { cwd: tmpDir, stdio: 'ignore' });
|
|
33
|
+
execSync('git config user.name Test', { cwd: tmpDir, stdio: 'ignore' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('getHeadSha returns the current HEAD commit hash', () => {
|
|
41
|
+
fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'content');
|
|
42
|
+
execSync('git add . && git commit -m "first"', { cwd: tmpDir, stdio: 'ignore' });
|
|
43
|
+
const sha = getHeadSha(tmpDir);
|
|
44
|
+
expect(sha).toBeTruthy();
|
|
45
|
+
expect(sha!.length).toBe(40);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('getHeadSha returns null when there are no commits', () => {
|
|
49
|
+
const sha = getHeadSha(tmpDir);
|
|
50
|
+
expect(sha).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('changedFilesSince returns [] when lastCommit === HEAD', () => {
|
|
54
|
+
fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'content');
|
|
55
|
+
execSync('git add . && git commit -m "first"', { cwd: tmpDir, stdio: 'ignore' });
|
|
56
|
+
const sha = getHeadSha(tmpDir)!;
|
|
57
|
+
const files = changedFilesSince(tmpDir, sha);
|
|
58
|
+
expect(files).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('changedFilesSince returns changed files since a previous commit', () => {
|
|
62
|
+
fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'v1');
|
|
63
|
+
execSync('git add . && git commit -m "first"', { cwd: tmpDir, stdio: 'ignore' });
|
|
64
|
+
const firstSha = getHeadSha(tmpDir)!;
|
|
65
|
+
|
|
66
|
+
// Second commit
|
|
67
|
+
fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'v2');
|
|
68
|
+
fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'new');
|
|
69
|
+
execSync('git add . && git commit -m "second"', { cwd: tmpDir, stdio: 'ignore' });
|
|
70
|
+
|
|
71
|
+
const files = changedFilesSince(tmpDir, firstSha);
|
|
72
|
+
expect(files).toContain('a.ts');
|
|
73
|
+
expect(files).toContain('b.ts');
|
|
74
|
+
});
|
|
75
|
+
});
|