wdyt 0.1.11 → 0.1.15

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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Symbol extraction module for wdyt
3
+ *
4
+ * Extracts function names, class names, type definitions, and interface names
5
+ * from source files in multiple languages.
6
+ */
7
+
8
+ /** Symbol types that can be extracted from source code */
9
+ export type SymbolType = "function" | "class" | "type" | "interface" | "const";
10
+
11
+ /** Extracted symbol information */
12
+ export interface Symbol {
13
+ name: string;
14
+ type: SymbolType;
15
+ line: number;
16
+ }
17
+
18
+ /** Language-specific regex patterns for symbol extraction */
19
+ interface LanguagePatterns {
20
+ patterns: Array<{
21
+ regex: RegExp;
22
+ type: SymbolType;
23
+ }>;
24
+ }
25
+
26
+ /**
27
+ * TypeScript/JavaScript patterns
28
+ * Matches: export function, async function, class, type, interface, const
29
+ */
30
+ const TYPESCRIPT_PATTERNS: LanguagePatterns = {
31
+ patterns: [
32
+ {
33
+ regex: /(?:export\s+)?(?:async\s+)?function\s+(\w+)/g,
34
+ type: "function",
35
+ },
36
+ {
37
+ regex: /(?:export\s+)?class\s+(\w+)/g,
38
+ type: "class",
39
+ },
40
+ {
41
+ regex: /(?:export\s+)?type\s+(\w+)/g,
42
+ type: "type",
43
+ },
44
+ {
45
+ regex: /(?:export\s+)?interface\s+(\w+)/g,
46
+ type: "interface",
47
+ },
48
+ {
49
+ // Match const with optional type annotation: const FOO: Type = or const FOO =
50
+ regex: /(?:export\s+)?const\s+(\w+)(?:\s*:\s*[^=]+)?\s*=/g,
51
+ type: "const",
52
+ },
53
+ ],
54
+ };
55
+
56
+ /**
57
+ * Python patterns
58
+ * Matches: def, async def, class (including indented methods)
59
+ */
60
+ const PYTHON_PATTERNS: LanguagePatterns = {
61
+ patterns: [
62
+ {
63
+ // Match def at any indentation level (for methods inside classes)
64
+ // Use [ \t]* for horizontal whitespace only (not newlines)
65
+ regex: /^[ \t]*(?:async\s+)?def\s+(\w+)/gm,
66
+ type: "function",
67
+ },
68
+ {
69
+ regex: /^class\s+(\w+)/gm,
70
+ type: "class",
71
+ },
72
+ ],
73
+ };
74
+
75
+ /**
76
+ * Go patterns
77
+ * Matches: func, type struct, type interface
78
+ */
79
+ const GO_PATTERNS: LanguagePatterns = {
80
+ patterns: [
81
+ {
82
+ regex: /^func\s+(?:\([^)]+\)\s+)?(\w+)/gm,
83
+ type: "function",
84
+ },
85
+ {
86
+ regex: /^type\s+(\w+)\s+struct/gm,
87
+ type: "class",
88
+ },
89
+ {
90
+ regex: /^type\s+(\w+)\s+interface/gm,
91
+ type: "interface",
92
+ },
93
+ ],
94
+ };
95
+
96
+ /**
97
+ * Rust patterns
98
+ * Matches: fn, pub fn, struct, pub struct, trait, pub trait, impl, type alias
99
+ */
100
+ const RUST_PATTERNS: LanguagePatterns = {
101
+ patterns: [
102
+ {
103
+ regex: /(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/g,
104
+ type: "function",
105
+ },
106
+ {
107
+ regex: /(?:pub\s+)?struct\s+(\w+)/g,
108
+ type: "class",
109
+ },
110
+ {
111
+ regex: /(?:pub\s+)?trait\s+(\w+)/g,
112
+ type: "interface",
113
+ },
114
+ {
115
+ // Match type alias with optional generic params: type Foo<T> = or type Foo =
116
+ regex: /(?:pub\s+)?type\s+(\w+)(?:<[^>]*>)?\s*=/g,
117
+ type: "type",
118
+ },
119
+ ],
120
+ };
121
+
122
+ /** File extensions to language pattern mapping */
123
+ const EXTENSION_PATTERNS: Record<string, LanguagePatterns> = {
124
+ ".ts": TYPESCRIPT_PATTERNS,
125
+ ".tsx": TYPESCRIPT_PATTERNS,
126
+ ".js": TYPESCRIPT_PATTERNS,
127
+ ".jsx": TYPESCRIPT_PATTERNS,
128
+ ".mjs": TYPESCRIPT_PATTERNS,
129
+ ".cjs": TYPESCRIPT_PATTERNS,
130
+ ".py": PYTHON_PATTERNS,
131
+ ".go": GO_PATTERNS,
132
+ ".rs": RUST_PATTERNS,
133
+ };
134
+
135
+ /**
136
+ * Get the file extension from a file path
137
+ */
138
+ function getExtension(filePath: string): string {
139
+ const lastDot = filePath.lastIndexOf(".");
140
+ if (lastDot === -1) return "";
141
+ return filePath.slice(lastDot).toLowerCase();
142
+ }
143
+
144
+ /**
145
+ * Get line number for a given character index in content
146
+ */
147
+ function getLineNumber(content: string, charIndex: number): number {
148
+ let lineNumber = 1;
149
+ for (let i = 0; i < charIndex && i < content.length; i++) {
150
+ if (content[i] === "\n") {
151
+ lineNumber++;
152
+ }
153
+ }
154
+ return lineNumber;
155
+ }
156
+
157
+ /**
158
+ * Extract symbols from file content
159
+ *
160
+ * @param content - The source file content
161
+ * @param filePath - The file path (used to determine language from extension)
162
+ * @returns Array of extracted symbols with name, type, and line number
163
+ */
164
+ export function extractSymbols(content: string, filePath: string): Symbol[] {
165
+ const extension = getExtension(filePath);
166
+ const languagePatterns = EXTENSION_PATTERNS[extension];
167
+
168
+ if (!languagePatterns) {
169
+ return [];
170
+ }
171
+
172
+ const symbols: Symbol[] = [];
173
+ const seen = new Set<string>();
174
+
175
+ for (const { regex, type } of languagePatterns.patterns) {
176
+ // Reset regex lastIndex for global patterns
177
+ regex.lastIndex = 0;
178
+
179
+ let match: RegExpExecArray | null;
180
+ while ((match = regex.exec(content)) !== null) {
181
+ const name = match[1];
182
+ const key = `${name}:${type}:${match.index}`;
183
+
184
+ // Avoid duplicates at the same position
185
+ if (seen.has(key)) continue;
186
+ seen.add(key);
187
+
188
+ symbols.push({
189
+ name,
190
+ type,
191
+ line: getLineNumber(content, match.index),
192
+ });
193
+ }
194
+ }
195
+
196
+ // Sort by line number
197
+ symbols.sort((a, b) => a.line - b.line);
198
+
199
+ return symbols;
200
+ }
201
+
202
+ /**
203
+ * Extract symbols from a file path
204
+ *
205
+ * @param filePath - Absolute path to the source file
206
+ * @returns Array of extracted symbols
207
+ */
208
+ export async function extractSymbolsFromFile(
209
+ filePath: string
210
+ ): Promise<Symbol[]> {
211
+ const file = Bun.file(filePath);
212
+
213
+ if (!(await file.exists())) {
214
+ return [];
215
+ }
216
+
217
+ const content = await file.text();
218
+ return extractSymbols(content, filePath);
219
+ }
220
+
221
+ /**
222
+ * Check if a file extension is supported for symbol extraction
223
+ */
224
+ export function isSupported(filePath: string): boolean {
225
+ const extension = getExtension(filePath);
226
+ return extension in EXTENSION_PATTERNS;
227
+ }
228
+
229
+ /**
230
+ * Get list of supported file extensions
231
+ */
232
+ export function getSupportedExtensions(): string[] {
233
+ return Object.keys(EXTENSION_PATTERNS);
234
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Flow module exports
3
+ *
4
+ * Provides Flow-Next task spec loading and related utilities.
5
+ */
6
+
7
+ export {
8
+ parseTaskId,
9
+ getEpicId,
10
+ isTaskId,
11
+ getSpecPath,
12
+ loadTaskSpec,
13
+ formatSpecXml,
14
+ getTaskSpecContext,
15
+ extractTaskIdFromPayload,
16
+ type TaskSpecResult,
17
+ type LoadTaskSpecOptions,
18
+ } from "./specs";
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Tests for Flow-Next spec loading module
3
+ */
4
+
5
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
6
+ import { mkdirSync, rmSync } from "fs";
7
+ import { join } from "path";
8
+ import {
9
+ parseTaskId,
10
+ getEpicId,
11
+ isTaskId,
12
+ getSpecPath,
13
+ loadTaskSpec,
14
+ formatSpecXml,
15
+ getTaskSpecContext,
16
+ extractTaskIdFromPayload,
17
+ } from "./specs";
18
+
19
+ // Test fixtures
20
+ const TEST_DIR = join(import.meta.dir, "..", "..", ".test-flow");
21
+ const TEST_FLOW_DIR = join(TEST_DIR, ".flow");
22
+
23
+ // Sample spec content - using valid fn-N or fn-N-suffix ID format
24
+ const SAMPLE_SPEC = `# fn-99-test.1 Test Task
25
+
26
+ ## Description
27
+ This is a test task for unit testing.
28
+
29
+ ### Requirements
30
+ - Requirement 1
31
+ - Requirement 2
32
+
33
+ ### Acceptance Criteria
34
+ - [ ] Criteria 1
35
+ - [ ] Criteria 2
36
+ `;
37
+
38
+ const SAMPLE_EPIC_SPEC = `# fn-99-test Test Epic
39
+
40
+ ## Overview
41
+ This is a test epic.
42
+
43
+ ## Tasks
44
+ - fn-99-test.1 - Test Task
45
+ `;
46
+
47
+ describe("parseTaskId", () => {
48
+ it("parses simple task ID", () => {
49
+ expect(parseTaskId("fn-1.2")).toBe("fn-1.2");
50
+ });
51
+
52
+ it("parses task ID with suffix", () => {
53
+ expect(parseTaskId("fn-2-vth.7")).toBe("fn-2-vth.7");
54
+ });
55
+
56
+ it("parses epic ID", () => {
57
+ expect(parseTaskId("fn-1")).toBe("fn-1");
58
+ });
59
+
60
+ it("parses epic ID with suffix", () => {
61
+ expect(parseTaskId("fn-2-vth")).toBe("fn-2-vth");
62
+ });
63
+
64
+ it("handles uppercase", () => {
65
+ expect(parseTaskId("FN-1.2")).toBe("fn-1.2");
66
+ });
67
+
68
+ it("handles whitespace", () => {
69
+ expect(parseTaskId(" fn-1.2 ")).toBe("fn-1.2");
70
+ });
71
+
72
+ it("rejects invalid formats", () => {
73
+ expect(parseTaskId("invalid")).toBeNull();
74
+ expect(parseTaskId("fn1.2")).toBeNull();
75
+ expect(parseTaskId("fn-")).toBeNull();
76
+ expect(parseTaskId("")).toBeNull();
77
+ expect(parseTaskId(null as unknown as string)).toBeNull();
78
+ });
79
+ });
80
+
81
+ describe("getEpicId", () => {
82
+ it("extracts epic from simple task", () => {
83
+ expect(getEpicId("fn-1.2")).toBe("fn-1");
84
+ });
85
+
86
+ it("extracts epic from task with suffix", () => {
87
+ expect(getEpicId("fn-2-vth.7")).toBe("fn-2-vth");
88
+ });
89
+
90
+ it("returns epic unchanged", () => {
91
+ expect(getEpicId("fn-1")).toBe("fn-1");
92
+ });
93
+
94
+ it("returns epic with suffix unchanged", () => {
95
+ expect(getEpicId("fn-2-vth")).toBe("fn-2-vth");
96
+ });
97
+ });
98
+
99
+ describe("isTaskId", () => {
100
+ it("identifies task IDs", () => {
101
+ expect(isTaskId("fn-1.2")).toBe(true);
102
+ expect(isTaskId("fn-2-vth.7")).toBe(true);
103
+ });
104
+
105
+ it("identifies epic IDs", () => {
106
+ expect(isTaskId("fn-1")).toBe(false);
107
+ expect(isTaskId("fn-2-vth")).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe("getSpecPath", () => {
112
+ it("returns task spec path", () => {
113
+ const path = getSpecPath("fn-1.2", { cwd: "/project" });
114
+ expect(path).toBe("/project/.flow/tasks/fn-1.2.md");
115
+ });
116
+
117
+ it("returns epic spec path", () => {
118
+ const path = getSpecPath("fn-1", { cwd: "/project" });
119
+ expect(path).toBe("/project/.flow/specs/fn-1.md");
120
+ });
121
+
122
+ it("uses custom flow directory", () => {
123
+ const path = getSpecPath("fn-1.2", { flowDir: "/custom/.flow" });
124
+ expect(path).toBe("/custom/.flow/tasks/fn-1.2.md");
125
+ });
126
+ });
127
+
128
+ describe("loadTaskSpec", () => {
129
+ beforeAll(() => {
130
+ // Create test directory structure
131
+ mkdirSync(join(TEST_FLOW_DIR, "tasks"), { recursive: true });
132
+ mkdirSync(join(TEST_FLOW_DIR, "specs"), { recursive: true });
133
+
134
+ // Write test spec files
135
+ Bun.write(join(TEST_FLOW_DIR, "tasks", "fn-99-test.1.md"), SAMPLE_SPEC);
136
+ Bun.write(join(TEST_FLOW_DIR, "specs", "fn-99-test.md"), SAMPLE_EPIC_SPEC);
137
+ });
138
+
139
+ afterAll(() => {
140
+ // Clean up test directory
141
+ rmSync(TEST_DIR, { recursive: true, force: true });
142
+ });
143
+
144
+ it("loads existing task spec", async () => {
145
+ const result = await loadTaskSpec("fn-99-test.1", { flowDir: TEST_FLOW_DIR });
146
+
147
+ expect(result.found).toBe(true);
148
+ expect(result.taskId).toBe("fn-99-test.1");
149
+ expect(result.content).toBe(SAMPLE_SPEC);
150
+ expect(result.path).toContain("fn-99-test.1.md");
151
+ });
152
+
153
+ it("loads existing epic spec", async () => {
154
+ const result = await loadTaskSpec("fn-99-test", { flowDir: TEST_FLOW_DIR });
155
+
156
+ expect(result.found).toBe(true);
157
+ expect(result.taskId).toBe("fn-99-test");
158
+ expect(result.content).toBe(SAMPLE_EPIC_SPEC);
159
+ expect(result.path).toContain("fn-99-test.md");
160
+ });
161
+
162
+ it("returns not found for missing spec", async () => {
163
+ const result = await loadTaskSpec("fn-88.99", { flowDir: TEST_FLOW_DIR });
164
+
165
+ expect(result.found).toBe(false);
166
+ expect(result.taskId).toBe("fn-88.99");
167
+ expect(result.error).toContain("not found");
168
+ });
169
+
170
+ it("returns error for invalid task ID", async () => {
171
+ const result = await loadTaskSpec("invalid", { flowDir: TEST_FLOW_DIR });
172
+
173
+ expect(result.found).toBe(false);
174
+ expect(result.error).toContain("Invalid task ID");
175
+ });
176
+ });
177
+
178
+ describe("formatSpecXml", () => {
179
+ it("formats found spec as XML", () => {
180
+ const result = {
181
+ found: true,
182
+ taskId: "fn-99-test.1",
183
+ content: SAMPLE_SPEC,
184
+ };
185
+
186
+ const xml = formatSpecXml(result);
187
+
188
+ expect(xml).toContain("<task_spec>");
189
+ expect(xml).toContain("</task_spec>");
190
+ expect(xml).toContain("# fn-99-test.1 Test Task");
191
+ });
192
+
193
+ it("returns empty string for not found", () => {
194
+ const result = {
195
+ found: false,
196
+ taskId: "fn-99-test.1",
197
+ error: "Not found",
198
+ };
199
+
200
+ const xml = formatSpecXml(result);
201
+ expect(xml).toBe("");
202
+ });
203
+ });
204
+
205
+ describe("getTaskSpecContext", () => {
206
+ beforeAll(() => {
207
+ // Create test directory structure
208
+ mkdirSync(join(TEST_FLOW_DIR, "tasks"), { recursive: true });
209
+ Bun.write(join(TEST_FLOW_DIR, "tasks", "fn-99-test.1.md"), SAMPLE_SPEC);
210
+ });
211
+
212
+ afterAll(() => {
213
+ rmSync(TEST_DIR, { recursive: true, force: true });
214
+ });
215
+
216
+ it("returns formatted XML for existing spec", async () => {
217
+ const xml = await getTaskSpecContext("fn-99-test.1", { flowDir: TEST_FLOW_DIR });
218
+
219
+ expect(xml).toContain("<task_spec>");
220
+ expect(xml).toContain("# fn-99-test.1 Test Task");
221
+ });
222
+
223
+ it("returns empty string for missing spec (graceful fallback)", async () => {
224
+ const xml = await getTaskSpecContext("fn-88.99", { flowDir: TEST_FLOW_DIR });
225
+
226
+ expect(xml).toBe("");
227
+ });
228
+ });
229
+
230
+ describe("extractTaskIdFromPayload", () => {
231
+ it("extracts task_id field", () => {
232
+ const payload = { task_id: "fn-1.2" };
233
+ expect(extractTaskIdFromPayload(payload)).toBe("fn-1.2");
234
+ });
235
+
236
+ it("extracts taskId field", () => {
237
+ const payload = { taskId: "fn-2-vth.7" };
238
+ expect(extractTaskIdFromPayload(payload)).toBe("fn-2-vth.7");
239
+ });
240
+
241
+ it("extracts task field", () => {
242
+ const payload = { task: "fn-1.2" };
243
+ expect(extractTaskIdFromPayload(payload)).toBe("fn-1.2");
244
+ });
245
+
246
+ it("returns null for missing field", () => {
247
+ const payload = { other: "value" };
248
+ expect(extractTaskIdFromPayload(payload)).toBeNull();
249
+ });
250
+
251
+ it("returns null for invalid task ID", () => {
252
+ const payload = { task_id: "invalid" };
253
+ expect(extractTaskIdFromPayload(payload)).toBeNull();
254
+ });
255
+
256
+ it("returns null for non-object", () => {
257
+ expect(extractTaskIdFromPayload(null)).toBeNull();
258
+ expect(extractTaskIdFromPayload("string")).toBeNull();
259
+ });
260
+ });