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,255 @@
1
+ /**
2
+ * Flow-Next spec loading module
3
+ *
4
+ * Loads task specs from the .flow/ directory for inclusion
5
+ * in code review context.
6
+ */
7
+
8
+ import { join } from "path";
9
+
10
+ /**
11
+ * Task ID pattern: fn-N.M or fn-N-suffix.M
12
+ * Examples: fn-1.2, fn-2-vth.7, fn-123-abc.45
13
+ */
14
+ const TASK_ID_REGEX = /^fn-\d+(?:-[a-z0-9]+)?(?:\.\d+)?$/i;
15
+
16
+ /**
17
+ * Result of loading a task spec
18
+ */
19
+ export interface TaskSpecResult {
20
+ /** Whether the spec was found and loaded */
21
+ found: boolean;
22
+ /** The task ID that was parsed */
23
+ taskId: string;
24
+ /** The spec content (markdown) */
25
+ content?: string;
26
+ /** The file path that was loaded from */
27
+ path?: string;
28
+ /** Error message if loading failed */
29
+ error?: string;
30
+ }
31
+
32
+ /**
33
+ * Options for loading task specs
34
+ */
35
+ export interface LoadTaskSpecOptions {
36
+ /** Working directory (defaults to cwd) */
37
+ cwd?: string;
38
+ /** Base path for .flow directory (defaults to cwd/.flow) */
39
+ flowDir?: string;
40
+ }
41
+
42
+ /**
43
+ * Parse and validate a task ID
44
+ *
45
+ * Valid formats:
46
+ * - fn-N (epic only, e.g., fn-1)
47
+ * - fn-N.M (simple task, e.g., fn-1.2)
48
+ * - fn-N-suffix (epic with suffix, e.g., fn-2-vth)
49
+ * - fn-N-suffix.M (task with suffix, e.g., fn-2-vth.7)
50
+ *
51
+ * @param input - The task ID string to parse
52
+ * @returns The validated task ID or null if invalid
53
+ */
54
+ export function parseTaskId(input: string): string | null {
55
+ if (!input || typeof input !== "string") {
56
+ return null;
57
+ }
58
+
59
+ const trimmed = input.trim().toLowerCase();
60
+
61
+ if (TASK_ID_REGEX.test(trimmed)) {
62
+ return trimmed;
63
+ }
64
+
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Extract the epic ID from a task ID
70
+ *
71
+ * Examples:
72
+ * - fn-1.2 -> fn-1
73
+ * - fn-2-vth.7 -> fn-2-vth
74
+ * - fn-1 -> fn-1
75
+ *
76
+ * @param taskId - The full task ID
77
+ * @returns The epic ID portion
78
+ */
79
+ export function getEpicId(taskId: string): string {
80
+ // Remove the .M suffix if present
81
+ const dotIndex = taskId.lastIndexOf(".");
82
+ if (dotIndex > 0 && /^\d+$/.test(taskId.slice(dotIndex + 1))) {
83
+ return taskId.slice(0, dotIndex);
84
+ }
85
+ return taskId;
86
+ }
87
+
88
+ /**
89
+ * Determine if an ID is a task (has .M suffix) or an epic
90
+ *
91
+ * @param taskId - The ID to check
92
+ * @returns True if this is a task ID (has .M suffix)
93
+ */
94
+ export function isTaskId(taskId: string): boolean {
95
+ const dotIndex = taskId.lastIndexOf(".");
96
+ if (dotIndex > 0) {
97
+ const suffix = taskId.slice(dotIndex + 1);
98
+ return /^\d+$/.test(suffix);
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * Get the path to a task spec file
105
+ *
106
+ * Task specs are stored at: .flow/tasks/{task-id}.md
107
+ * Epic specs are stored at: .flow/specs/{epic-id}.md
108
+ *
109
+ * @param taskId - The task or epic ID
110
+ * @param options - Loading options
111
+ * @returns The absolute path to the spec file
112
+ */
113
+ export function getSpecPath(taskId: string, options: LoadTaskSpecOptions = {}): string {
114
+ const { cwd = process.cwd() } = options;
115
+ const flowDir = options.flowDir || join(cwd, ".flow");
116
+
117
+ if (isTaskId(taskId)) {
118
+ // Task spec: .flow/tasks/fn-N.M.md
119
+ return join(flowDir, "tasks", `${taskId}.md`);
120
+ } else {
121
+ // Epic spec: .flow/specs/fn-N.md
122
+ return join(flowDir, "specs", `${taskId}.md`);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Load a task spec from the .flow directory
128
+ *
129
+ * @param taskId - The task ID to load (e.g., fn-2-vth.7)
130
+ * @param options - Loading options
131
+ * @returns Result containing the spec content or error
132
+ */
133
+ export async function loadTaskSpec(
134
+ taskId: string,
135
+ options: LoadTaskSpecOptions = {}
136
+ ): Promise<TaskSpecResult> {
137
+ // Validate and normalize the task ID
138
+ const parsed = parseTaskId(taskId);
139
+
140
+ if (!parsed) {
141
+ return {
142
+ found: false,
143
+ taskId,
144
+ error: `Invalid task ID format: ${taskId}`,
145
+ };
146
+ }
147
+
148
+ const specPath = getSpecPath(parsed, options);
149
+
150
+ try {
151
+ const file = Bun.file(specPath);
152
+
153
+ if (!(await file.exists())) {
154
+ return {
155
+ found: false,
156
+ taskId: parsed,
157
+ path: specPath,
158
+ error: `Spec file not found: ${specPath}`,
159
+ };
160
+ }
161
+
162
+ const content = await file.text();
163
+
164
+ return {
165
+ found: true,
166
+ taskId: parsed,
167
+ content,
168
+ path: specPath,
169
+ };
170
+ } catch (error) {
171
+ const message = error instanceof Error ? error.message : String(error);
172
+ return {
173
+ found: false,
174
+ taskId: parsed,
175
+ path: specPath,
176
+ error: `Failed to read spec: ${message}`,
177
+ };
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Format a task spec as XML for inclusion in review context
183
+ *
184
+ * Output format:
185
+ * ```xml
186
+ * <task_spec>
187
+ * ## Task: fn-2.3 - Context hints generation
188
+ *
189
+ * ### Requirements
190
+ * ...
191
+ *
192
+ * ### Acceptance Criteria
193
+ * ...
194
+ * </task_spec>
195
+ * ```
196
+ *
197
+ * @param result - The task spec result from loadTaskSpec
198
+ * @returns Formatted XML string, or empty string if no spec found
199
+ */
200
+ export function formatSpecXml(result: TaskSpecResult): string {
201
+ if (!result.found || !result.content) {
202
+ return "";
203
+ }
204
+
205
+ return `<task_spec>\n${result.content.trim()}\n</task_spec>`;
206
+ }
207
+
208
+ /**
209
+ * Load and format a task spec for inclusion in review context
210
+ *
211
+ * This is the main entry point for getting task spec context.
212
+ * Returns empty string if spec is not found (graceful fallback).
213
+ *
214
+ * @param taskId - The task ID to load (e.g., fn-2-vth.7)
215
+ * @param options - Loading options
216
+ * @returns Formatted XML spec string, or empty string if not found
217
+ */
218
+ export async function getTaskSpecContext(
219
+ taskId: string,
220
+ options: LoadTaskSpecOptions = {}
221
+ ): Promise<string> {
222
+ const result = await loadTaskSpec(taskId, options);
223
+ return formatSpecXml(result);
224
+ }
225
+
226
+ /**
227
+ * Extract task ID from a payload object
228
+ *
229
+ * Looks for task_id field in the payload.
230
+ *
231
+ * @param payload - Payload object that may contain task_id
232
+ * @returns The task ID or null if not found
233
+ */
234
+ export function extractTaskIdFromPayload(payload: unknown): string | null {
235
+ if (!payload || typeof payload !== "object") {
236
+ return null;
237
+ }
238
+
239
+ const obj = payload as Record<string, unknown>;
240
+
241
+ // Check common field names
242
+ const taskIdFields = ["task_id", "taskId", "task"];
243
+
244
+ for (const field of taskIdFields) {
245
+ const value = obj[field];
246
+ if (typeof value === "string") {
247
+ const parsed = parseTaskId(value);
248
+ if (parsed) {
249
+ return parsed;
250
+ }
251
+ }
252
+ }
253
+
254
+ return null;
255
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Tests for git diff context module
3
+ */
4
+
5
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
6
+ import {
7
+ getDiffStat,
8
+ getCommits,
9
+ getChangedFiles,
10
+ getBranchName,
11
+ getGitDiffContext,
12
+ formatDiffContextXml,
13
+ getFormattedDiffContext,
14
+ type GitDiffContext,
15
+ } from "./diff";
16
+ import { join } from "path";
17
+ import { mkdir, writeFile, rm } from "fs/promises";
18
+ import { spawn } from "child_process";
19
+
20
+ // Test fixtures directory
21
+ const FIXTURES_DIR = join(import.meta.dir, "..", "..", ".test-fixtures-diff");
22
+
23
+ /**
24
+ * Helper to run git commands
25
+ */
26
+ async function git(args: string[], cwd: string): Promise<void> {
27
+ return new Promise((resolve, reject) => {
28
+ const process = spawn("git", args, { cwd, stdio: "ignore" });
29
+ process.on("close", (code) => {
30
+ if (code === 0) resolve();
31
+ else reject(new Error(`git ${args.join(" ")} failed with code ${code}`));
32
+ });
33
+ process.on("error", reject);
34
+ });
35
+ }
36
+
37
+ describe("Git diff context", () => {
38
+ // Set up a temp git repo with commits on different branches
39
+ beforeAll(async () => {
40
+ await mkdir(FIXTURES_DIR, { recursive: true });
41
+
42
+ // Initialize git repo with explicit main branch
43
+ await git(["init", "-b", "main"], FIXTURES_DIR);
44
+ await git(["config", "user.email", "test@test.com"], FIXTURES_DIR);
45
+ await git(["config", "user.name", "Test User"], FIXTURES_DIR);
46
+
47
+ // Create initial commit on main
48
+ await writeFile(join(FIXTURES_DIR, "README.md"), "# Test Project\n");
49
+ await git(["add", "."], FIXTURES_DIR);
50
+ await git(["commit", "-m", "Initial commit"], FIXTURES_DIR);
51
+
52
+ // Create a feature branch
53
+ await git(["checkout", "-b", "feature/test"], FIXTURES_DIR);
54
+
55
+ // Add some changes
56
+ await mkdir(join(FIXTURES_DIR, "src"), { recursive: true });
57
+ await writeFile(
58
+ join(FIXTURES_DIR, "src/auth.ts"),
59
+ `export function authenticate() {
60
+ return true;
61
+ }
62
+ `
63
+ );
64
+ await git(["add", "."], FIXTURES_DIR);
65
+ await git(["commit", "-m", "feat: add auth"], FIXTURES_DIR);
66
+
67
+ // Add another commit
68
+ await writeFile(
69
+ join(FIXTURES_DIR, "src/types.ts"),
70
+ `export type User = {
71
+ id: string;
72
+ name: string;
73
+ };
74
+ `
75
+ );
76
+ await git(["add", "."], FIXTURES_DIR);
77
+ await git(["commit", "-m", "feat: add types"], FIXTURES_DIR);
78
+ });
79
+
80
+ afterAll(async () => {
81
+ await rm(FIXTURES_DIR, { recursive: true, force: true });
82
+ });
83
+
84
+ describe("getDiffStat", () => {
85
+ test("returns diff stat between branches", async () => {
86
+ const stat = await getDiffStat({
87
+ base: "main",
88
+ head: "HEAD",
89
+ cwd: FIXTURES_DIR,
90
+ });
91
+
92
+ expect(stat).toContain("src/auth.ts");
93
+ expect(stat).toContain("src/types.ts");
94
+ // Should show file stats like "+ insertions" pattern
95
+ expect(stat).toMatch(/\d+\s+(file|files)\s+changed/);
96
+ });
97
+
98
+ test("returns empty string for invalid base", async () => {
99
+ const stat = await getDiffStat({
100
+ base: "nonexistent-branch",
101
+ cwd: FIXTURES_DIR,
102
+ });
103
+
104
+ expect(stat).toBe("");
105
+ });
106
+
107
+ test("uses default base of main", async () => {
108
+ const stat = await getDiffStat({
109
+ cwd: FIXTURES_DIR,
110
+ });
111
+
112
+ // Should work with default main base
113
+ expect(typeof stat).toBe("string");
114
+ });
115
+ });
116
+
117
+ describe("getCommits", () => {
118
+ test("returns commit history", async () => {
119
+ const commits = await getCommits({
120
+ base: "main",
121
+ cwd: FIXTURES_DIR,
122
+ });
123
+
124
+ expect(commits.length).toBe(2);
125
+ expect(commits.some((c) => c.includes("add auth"))).toBe(true);
126
+ expect(commits.some((c) => c.includes("add types"))).toBe(true);
127
+ });
128
+
129
+ test("returns empty array for invalid base", async () => {
130
+ const commits = await getCommits({
131
+ base: "nonexistent-branch",
132
+ cwd: FIXTURES_DIR,
133
+ });
134
+
135
+ expect(commits).toEqual([]);
136
+ });
137
+
138
+ test("returns empty array when no commits between refs", async () => {
139
+ const commits = await getCommits({
140
+ base: "HEAD",
141
+ head: "HEAD",
142
+ cwd: FIXTURES_DIR,
143
+ });
144
+
145
+ expect(commits).toEqual([]);
146
+ });
147
+ });
148
+
149
+ describe("getChangedFiles", () => {
150
+ test("returns list of changed files", async () => {
151
+ const files = await getChangedFiles({
152
+ base: "main",
153
+ cwd: FIXTURES_DIR,
154
+ });
155
+
156
+ expect(files).toContain("src/auth.ts");
157
+ expect(files).toContain("src/types.ts");
158
+ });
159
+
160
+ test("returns empty array for invalid base", async () => {
161
+ const files = await getChangedFiles({
162
+ base: "nonexistent-branch",
163
+ cwd: FIXTURES_DIR,
164
+ });
165
+
166
+ expect(files).toEqual([]);
167
+ });
168
+ });
169
+
170
+ describe("getBranchName", () => {
171
+ test("returns current branch name", async () => {
172
+ const branch = await getBranchName(FIXTURES_DIR);
173
+
174
+ expect(branch).toBe("feature/test");
175
+ });
176
+
177
+ test("returns empty string for invalid cwd", async () => {
178
+ const branch = await getBranchName("/nonexistent/path");
179
+
180
+ expect(branch).toBe("");
181
+ });
182
+ });
183
+
184
+ describe("getGitDiffContext", () => {
185
+ test("returns full git context", async () => {
186
+ const context = await getGitDiffContext({
187
+ base: "main",
188
+ cwd: FIXTURES_DIR,
189
+ });
190
+
191
+ expect(context.diffStat).toContain("src/auth.ts");
192
+ expect(context.commits.length).toBe(2);
193
+ expect(context.changedFiles).toContain("src/auth.ts");
194
+ expect(context.changedFiles).toContain("src/types.ts");
195
+ expect(context.branch).toBe("feature/test");
196
+ });
197
+
198
+ test("handles missing base gracefully", async () => {
199
+ const context = await getGitDiffContext({
200
+ base: "nonexistent",
201
+ cwd: FIXTURES_DIR,
202
+ });
203
+
204
+ // Should return empty values rather than throwing
205
+ expect(context.diffStat).toBe("");
206
+ expect(context.commits).toEqual([]);
207
+ expect(context.changedFiles).toEqual([]);
208
+ // Branch should still work
209
+ expect(context.branch).toBe("feature/test");
210
+ });
211
+ });
212
+
213
+ describe("formatDiffContextXml", () => {
214
+ test("formats context as XML", () => {
215
+ const context: GitDiffContext = {
216
+ diffStat:
217
+ " src/auth.ts | 45 +++\n src/types.ts | 12 ++\n 2 files changed, 57 insertions(+)",
218
+ commits: ["abc1234 feat: add auth", "def5678 feat: add types"],
219
+ changedFiles: ["src/auth.ts", "src/types.ts"],
220
+ branch: "feature/test",
221
+ };
222
+
223
+ const xml = formatDiffContextXml(context);
224
+
225
+ expect(xml).toContain("<diff_summary>");
226
+ expect(xml).toContain("</diff_summary>");
227
+ expect(xml).toContain("<commits>");
228
+ expect(xml).toContain("</commits>");
229
+ expect(xml).toContain("<changed_files>");
230
+ expect(xml).toContain("</changed_files>");
231
+
232
+ expect(xml).toContain("src/auth.ts");
233
+ expect(xml).toContain("abc1234 feat: add auth");
234
+ });
235
+
236
+ test("omits empty sections", () => {
237
+ const context: GitDiffContext = {
238
+ diffStat: "",
239
+ commits: [],
240
+ changedFiles: ["file.ts"],
241
+ branch: "main",
242
+ };
243
+
244
+ const xml = formatDiffContextXml(context);
245
+
246
+ expect(xml).not.toContain("<diff_summary>");
247
+ expect(xml).not.toContain("<commits>");
248
+ expect(xml).toContain("<changed_files>");
249
+ });
250
+
251
+ test("handles all empty context", () => {
252
+ const context: GitDiffContext = {
253
+ diffStat: "",
254
+ commits: [],
255
+ changedFiles: [],
256
+ branch: "",
257
+ };
258
+
259
+ const xml = formatDiffContextXml(context);
260
+
261
+ expect(xml).toBe("");
262
+ });
263
+ });
264
+
265
+ describe("getFormattedDiffContext", () => {
266
+ test("returns formatted XML context", async () => {
267
+ const xml = await getFormattedDiffContext({
268
+ base: "main",
269
+ cwd: FIXTURES_DIR,
270
+ });
271
+
272
+ expect(xml).toContain("<diff_summary>");
273
+ expect(xml).toContain("<commits>");
274
+ expect(xml).toContain("<changed_files>");
275
+ expect(xml).toContain("src/auth.ts");
276
+ });
277
+ });
278
+
279
+ describe("Edge cases", () => {
280
+ test("handles cwd with spaces in path", async () => {
281
+ // This test verifies the module doesn't break with special paths
282
+ // The actual FIXTURES_DIR doesn't have spaces, but the code should handle it
283
+ const branch = await getBranchName(FIXTURES_DIR);
284
+ expect(typeof branch).toBe("string");
285
+ });
286
+
287
+ test("all functions handle non-git directory", async () => {
288
+ const tempDir = "/tmp/non-git-test-" + Date.now();
289
+ await mkdir(tempDir, { recursive: true });
290
+
291
+ try {
292
+ const stat = await getDiffStat({ cwd: tempDir });
293
+ const commits = await getCommits({ cwd: tempDir });
294
+ const files = await getChangedFiles({ cwd: tempDir });
295
+ const branch = await getBranchName(tempDir);
296
+ const context = await getGitDiffContext({ cwd: tempDir });
297
+ const formatted = await getFormattedDiffContext({ cwd: tempDir });
298
+
299
+ // All should return empty/default values
300
+ expect(stat).toBe("");
301
+ expect(commits).toEqual([]);
302
+ expect(files).toEqual([]);
303
+ expect(branch).toBe("");
304
+ expect(context.diffStat).toBe("");
305
+ expect(formatted).toBe("");
306
+ } finally {
307
+ await rm(tempDir, { recursive: true, force: true });
308
+ }
309
+ });
310
+ });
311
+ });