@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,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
+ }
@@ -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
+ });