@cliangdev/flux-plugin 0.2.0 → 0.3.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 (108) hide show
  1. package/README.md +11 -7
  2. package/agents/coder.md +150 -25
  3. package/bin/install.cjs +171 -16
  4. package/commands/breakdown.md +47 -10
  5. package/commands/dashboard.md +29 -0
  6. package/commands/flux.md +92 -12
  7. package/commands/implement.md +166 -17
  8. package/commands/linear.md +6 -5
  9. package/commands/prd.md +996 -82
  10. package/manifest.json +2 -1
  11. package/package.json +9 -11
  12. package/skills/flux-orchestrator/SKILL.md +11 -3
  13. package/skills/prd-writer/SKILL.md +761 -0
  14. package/skills/ux-ui-design/SKILL.md +346 -0
  15. package/skills/ux-ui-design/references/design-tokens.md +359 -0
  16. package/src/__tests__/version.test.ts +37 -0
  17. package/src/adapters/local/.gitkeep +0 -0
  18. package/src/dashboard/__tests__/api.test.ts +211 -0
  19. package/src/dashboard/browser.ts +35 -0
  20. package/src/dashboard/public/app.js +869 -0
  21. package/src/dashboard/public/index.html +90 -0
  22. package/src/dashboard/public/styles.css +807 -0
  23. package/src/dashboard/public/vendor/highlight.css +10 -0
  24. package/src/dashboard/public/vendor/highlight.min.js +8422 -0
  25. package/src/dashboard/public/vendor/marked.min.js +2210 -0
  26. package/src/dashboard/server.ts +296 -0
  27. package/src/dashboard/watchers.ts +83 -0
  28. package/src/server/__tests__/config.test.ts +163 -0
  29. package/src/server/adapters/__tests__/a-client-linear.test.ts +197 -0
  30. package/src/server/adapters/__tests__/adapter-factory.test.ts +230 -0
  31. package/src/server/adapters/__tests__/dependency-ops.test.ts +429 -0
  32. package/src/server/adapters/__tests__/document-ops.test.ts +306 -0
  33. package/src/server/adapters/__tests__/linear-adapter.test.ts +91 -0
  34. package/src/server/adapters/__tests__/linear-config.test.ts +425 -0
  35. package/src/server/adapters/__tests__/linear-criteria-parser.test.ts +287 -0
  36. package/src/server/adapters/__tests__/linear-description-test.ts +238 -0
  37. package/src/server/adapters/__tests__/linear-epic-crud.test.ts +496 -0
  38. package/src/server/adapters/__tests__/linear-mappers-description.test.ts +276 -0
  39. package/src/server/adapters/__tests__/linear-mappers-epic.test.ts +294 -0
  40. package/src/server/adapters/__tests__/linear-mappers-prd.test.ts +300 -0
  41. package/src/server/adapters/__tests__/linear-mappers-task.test.ts +197 -0
  42. package/src/server/adapters/__tests__/linear-prd-crud.test.ts +620 -0
  43. package/src/server/adapters/__tests__/linear-stats.test.ts +450 -0
  44. package/src/server/adapters/__tests__/linear-task-crud.test.ts +534 -0
  45. package/src/server/adapters/__tests__/linear-types.test.ts +243 -0
  46. package/src/server/adapters/__tests__/status-ops.test.ts +441 -0
  47. package/src/server/adapters/factory.ts +90 -0
  48. package/src/server/adapters/index.ts +9 -0
  49. package/src/server/adapters/linear/adapter.ts +1141 -0
  50. package/src/server/adapters/linear/client.ts +169 -0
  51. package/src/server/adapters/linear/config.ts +152 -0
  52. package/src/server/adapters/linear/helpers/criteria-parser.ts +197 -0
  53. package/src/server/adapters/linear/helpers/index.ts +7 -0
  54. package/src/server/adapters/linear/index.ts +16 -0
  55. package/src/server/adapters/linear/mappers/description.ts +136 -0
  56. package/src/server/adapters/linear/mappers/epic.ts +81 -0
  57. package/src/server/adapters/linear/mappers/index.ts +27 -0
  58. package/src/server/adapters/linear/mappers/prd.ts +178 -0
  59. package/src/server/adapters/linear/mappers/task.ts +82 -0
  60. package/src/server/adapters/linear/types.ts +264 -0
  61. package/src/server/adapters/local-adapter.ts +1009 -0
  62. package/src/server/adapters/types.ts +293 -0
  63. package/src/server/config.ts +73 -0
  64. package/src/server/db/__tests__/queries.test.ts +473 -0
  65. package/src/server/db/ids.ts +17 -0
  66. package/src/server/db/index.ts +69 -0
  67. package/src/server/db/queries.ts +142 -0
  68. package/src/server/db/refs.ts +60 -0
  69. package/src/server/db/schema.ts +97 -0
  70. package/src/server/db/sqlite.ts +10 -0
  71. package/src/server/index.ts +81 -0
  72. package/src/server/tools/__tests__/crud.test.ts +411 -0
  73. package/src/server/tools/__tests__/get-version.test.ts +27 -0
  74. package/src/server/tools/__tests__/mcp-interface.test.ts +479 -0
  75. package/src/server/tools/__tests__/query.test.ts +405 -0
  76. package/src/server/tools/__tests__/z-configure-linear.test.ts +511 -0
  77. package/src/server/tools/__tests__/z-get-linear-url.test.ts +108 -0
  78. package/src/server/tools/configure-linear.ts +373 -0
  79. package/src/server/tools/create-epic.ts +44 -0
  80. package/src/server/tools/create-prd.ts +40 -0
  81. package/src/server/tools/create-task.ts +47 -0
  82. package/src/server/tools/criteria.ts +50 -0
  83. package/src/server/tools/delete-entity.ts +76 -0
  84. package/src/server/tools/dependencies.ts +55 -0
  85. package/src/server/tools/get-entity.ts +240 -0
  86. package/src/server/tools/get-linear-url.ts +28 -0
  87. package/src/server/tools/get-stats.ts +52 -0
  88. package/src/server/tools/get-version.ts +20 -0
  89. package/src/server/tools/index.ts +158 -0
  90. package/src/server/tools/init-project.ts +108 -0
  91. package/src/server/tools/query-entities.ts +167 -0
  92. package/src/server/tools/render-status.ts +219 -0
  93. package/src/server/tools/update-entity.ts +140 -0
  94. package/src/server/tools/update-status.ts +166 -0
  95. package/src/server/utils/__tests__/mcp-response.test.ts +331 -0
  96. package/src/server/utils/logger.ts +9 -0
  97. package/src/server/utils/mcp-response.ts +254 -0
  98. package/src/server/utils/status-transitions.ts +160 -0
  99. package/src/status-line/__tests__/status-line.test.ts +215 -0
  100. package/src/status-line/index.ts +147 -0
  101. package/src/utils/__tests__/chalk-import.test.ts +32 -0
  102. package/src/utils/__tests__/display.test.ts +97 -0
  103. package/src/utils/__tests__/status-renderer.test.ts +310 -0
  104. package/src/utils/display.ts +62 -0
  105. package/src/utils/status-renderer.ts +214 -0
  106. package/src/version.ts +5 -0
  107. package/dist/server/index.js +0 -87063
  108. package/skills/prd-template/SKILL.md +0 -242
@@ -0,0 +1,425 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+
12
+ describe("Linear Config", () => {
13
+ const originalEnv = process.env.FLUX_PROJECT_ROOT;
14
+ const TEST_DIR = `${realpathSync(tmpdir())}/flux-linear-config-test-${Date.now()}`;
15
+ const FLUX_DIR = `${TEST_DIR}/.flux`;
16
+ const CONFIG_PATH = `${FLUX_DIR}/linear-config.json`;
17
+
18
+ beforeEach(async () => {
19
+ // Clean up any previous test directory
20
+ if (existsSync(TEST_DIR)) {
21
+ rmSync(TEST_DIR, { recursive: true });
22
+ }
23
+
24
+ // Create test directory structure
25
+ mkdirSync(FLUX_DIR, { recursive: true });
26
+
27
+ // Set project root to test directory
28
+ process.env.FLUX_PROJECT_ROOT = TEST_DIR;
29
+
30
+ // Clear the config cache
31
+ const { config } = await import("../../config.js");
32
+ config.clearCache();
33
+ });
34
+
35
+ afterEach(async () => {
36
+ // Restore original env
37
+ if (originalEnv !== undefined) {
38
+ process.env.FLUX_PROJECT_ROOT = originalEnv;
39
+ } else {
40
+ delete process.env.FLUX_PROJECT_ROOT;
41
+ }
42
+
43
+ // Clear the config cache
44
+ const { config } = await import("../../config.js");
45
+ config.clearCache();
46
+
47
+ // Clean up test directory
48
+ if (existsSync(TEST_DIR)) {
49
+ rmSync(TEST_DIR, { recursive: true });
50
+ }
51
+ });
52
+
53
+ describe("linearConfigExists", () => {
54
+ test("returns true when config file exists", async () => {
55
+ writeFileSync(
56
+ CONFIG_PATH,
57
+ JSON.stringify({
58
+ apiKey: "lin_api_test",
59
+ teamId: "TEAM-123",
60
+ projectId: "proj_123",
61
+ }),
62
+ );
63
+
64
+ const { linearConfigExists } = await import("../linear/config.js");
65
+ expect(linearConfigExists()).toBe(true);
66
+ });
67
+
68
+ test("returns false when config file does not exist", async () => {
69
+ const { linearConfigExists } = await import("../linear/config.js");
70
+ expect(linearConfigExists()).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("validateLinearConfig", () => {
75
+ test("validates valid config with all fields", async () => {
76
+ const validConfig = {
77
+ apiKey: "lin_api_test123",
78
+ teamId: "TEAM-123",
79
+ projectId: "proj_abc123",
80
+ defaultLabels: {
81
+ prd: "prd",
82
+ epic: "epic",
83
+ task: "task",
84
+ },
85
+ };
86
+
87
+ const { validateLinearConfig } = await import("../linear/config.js");
88
+ const result = validateLinearConfig(validConfig);
89
+
90
+ expect(result.apiKey).toBe("lin_api_test123");
91
+ expect(result.teamId).toBe("TEAM-123");
92
+ expect(result.projectId).toBe("proj_abc123");
93
+ expect(result.defaultLabels.prd).toBe("prd");
94
+ expect(result.defaultLabels.epic).toBe("epic");
95
+ expect(result.defaultLabels.task).toBe("task");
96
+ });
97
+
98
+ test("validates valid config with optional defaultLabels omitted", async () => {
99
+ const validConfig = {
100
+ apiKey: "lin_api_test123",
101
+ teamId: "TEAM-123",
102
+ projectId: "proj_abc123",
103
+ };
104
+
105
+ const { validateLinearConfig } = await import("../linear/config.js");
106
+ const result = validateLinearConfig(validConfig);
107
+
108
+ expect(result.apiKey).toBe("lin_api_test123");
109
+ expect(result.teamId).toBe("TEAM-123");
110
+ expect(result.projectId).toBe("proj_abc123");
111
+ expect(result.defaultLabels.prd).toBe("prd");
112
+ expect(result.defaultLabels.epic).toBe("epic");
113
+ expect(result.defaultLabels.task).toBe("task");
114
+ });
115
+
116
+ test("throws error when apiKey is missing", async () => {
117
+ const invalidConfig = {
118
+ teamId: "TEAM-123",
119
+ projectId: "proj_abc123",
120
+ };
121
+
122
+ const { validateLinearConfig } = await import("../linear/config.js");
123
+
124
+ expect(() => validateLinearConfig(invalidConfig)).toThrow(
125
+ "Invalid Linear config: apiKey is required",
126
+ );
127
+ });
128
+
129
+ test("throws error when teamId is missing", async () => {
130
+ const invalidConfig = {
131
+ apiKey: "lin_api_test123",
132
+ projectId: "proj_abc123",
133
+ };
134
+
135
+ const { validateLinearConfig } = await import("../linear/config.js");
136
+
137
+ expect(() => validateLinearConfig(invalidConfig)).toThrow(
138
+ "Invalid Linear config: teamId is required",
139
+ );
140
+ });
141
+
142
+ test("throws error when projectId is missing", async () => {
143
+ const invalidConfig = {
144
+ apiKey: "lin_api_test123",
145
+ teamId: "TEAM-123",
146
+ };
147
+
148
+ const { validateLinearConfig } = await import("../linear/config.js");
149
+
150
+ expect(() => validateLinearConfig(invalidConfig)).toThrow(
151
+ "Invalid Linear config: projectId is required",
152
+ );
153
+ });
154
+
155
+ test("throws error when config is not an object", async () => {
156
+ const { validateLinearConfig } = await import("../linear/config.js");
157
+
158
+ expect(() => validateLinearConfig(null)).toThrow(
159
+ "Invalid Linear config: must be an object",
160
+ );
161
+ expect(() => validateLinearConfig("string")).toThrow(
162
+ "Invalid Linear config: must be an object",
163
+ );
164
+ expect(() => validateLinearConfig(123)).toThrow(
165
+ "Invalid Linear config: must be an object",
166
+ );
167
+ });
168
+
169
+ test("throws error when apiKey is not a string", async () => {
170
+ const invalidConfig = {
171
+ apiKey: 123,
172
+ teamId: "TEAM-123",
173
+ projectId: "proj_abc123",
174
+ };
175
+
176
+ const { validateLinearConfig } = await import("../linear/config.js");
177
+
178
+ expect(() => validateLinearConfig(invalidConfig)).toThrow(
179
+ "Invalid Linear config: apiKey must be a string",
180
+ );
181
+ });
182
+
183
+ test("throws error when teamId is not a string", async () => {
184
+ const invalidConfig = {
185
+ apiKey: "lin_api_test",
186
+ teamId: 123,
187
+ projectId: "proj_abc123",
188
+ };
189
+
190
+ const { validateLinearConfig } = await import("../linear/config.js");
191
+
192
+ expect(() => validateLinearConfig(invalidConfig)).toThrow(
193
+ "Invalid Linear config: teamId must be a string",
194
+ );
195
+ });
196
+
197
+ test("throws error when projectId is not a string", async () => {
198
+ const invalidConfig = {
199
+ apiKey: "lin_api_test",
200
+ teamId: "TEAM-123",
201
+ projectId: 123,
202
+ };
203
+
204
+ const { validateLinearConfig } = await import("../linear/config.js");
205
+
206
+ expect(() => validateLinearConfig(invalidConfig)).toThrow(
207
+ "Invalid Linear config: projectId must be a string",
208
+ );
209
+ });
210
+
211
+ test("uses default labels when defaultLabels is partially provided", async () => {
212
+ const configWithPartialLabels = {
213
+ apiKey: "lin_api_test",
214
+ teamId: "TEAM-123",
215
+ projectId: "proj_abc123",
216
+ defaultLabels: {
217
+ epic: "custom-epic",
218
+ },
219
+ };
220
+
221
+ const { validateLinearConfig } = await import("../linear/config.js");
222
+ const result = validateLinearConfig(configWithPartialLabels);
223
+
224
+ expect(result.defaultLabels.prd).toBe("prd");
225
+ expect(result.defaultLabels.epic).toBe("custom-epic");
226
+ expect(result.defaultLabels.task).toBe("task");
227
+ });
228
+
229
+ test("allows custom prd label", async () => {
230
+ const configWithCustomPrdLabel = {
231
+ apiKey: "lin_api_test",
232
+ teamId: "TEAM-123",
233
+ projectId: "proj_abc123",
234
+ defaultLabels: {
235
+ prd: "custom-prd",
236
+ },
237
+ };
238
+
239
+ const { validateLinearConfig } = await import("../linear/config.js");
240
+ const result = validateLinearConfig(configWithCustomPrdLabel);
241
+
242
+ expect(result.defaultLabels.prd).toBe("custom-prd");
243
+ expect(result.defaultLabels.epic).toBe("epic");
244
+ expect(result.defaultLabels.task).toBe("task");
245
+ });
246
+ });
247
+
248
+ describe("loadLinearConfig", () => {
249
+ test("loads valid config from file", async () => {
250
+ writeFileSync(
251
+ CONFIG_PATH,
252
+ JSON.stringify({
253
+ apiKey: "lin_api_test123",
254
+ teamId: "TEAM-123",
255
+ projectId: "proj_abc123",
256
+ defaultLabels: {
257
+ prd: "prd",
258
+ epic: "epic",
259
+ task: "task",
260
+ },
261
+ }),
262
+ );
263
+
264
+ const { loadLinearConfig } = await import("../linear/config.js");
265
+ const config = loadLinearConfig();
266
+
267
+ expect(config.apiKey).toBe("lin_api_test123");
268
+ expect(config.teamId).toBe("TEAM-123");
269
+ expect(config.projectId).toBe("proj_abc123");
270
+ expect(config.defaultLabels.prd).toBe("prd");
271
+ expect(config.defaultLabels.epic).toBe("epic");
272
+ expect(config.defaultLabels.task).toBe("task");
273
+ });
274
+
275
+ test("loads config with default labels when not provided", async () => {
276
+ writeFileSync(
277
+ CONFIG_PATH,
278
+ JSON.stringify({
279
+ apiKey: "lin_api_test123",
280
+ teamId: "TEAM-123",
281
+ projectId: "proj_abc123",
282
+ }),
283
+ );
284
+
285
+ const { loadLinearConfig } = await import("../linear/config.js");
286
+ const config = loadLinearConfig();
287
+
288
+ expect(config.apiKey).toBe("lin_api_test123");
289
+ expect(config.teamId).toBe("TEAM-123");
290
+ expect(config.projectId).toBe("proj_abc123");
291
+ expect(config.defaultLabels.prd).toBe("prd");
292
+ expect(config.defaultLabels.epic).toBe("epic");
293
+ expect(config.defaultLabels.task).toBe("task");
294
+ });
295
+
296
+ test("throws error when config file does not exist", async () => {
297
+ const { loadLinearConfig } = await import("../linear/config.js");
298
+
299
+ expect(() => loadLinearConfig()).toThrow(
300
+ "Linear config not found. Run configure_linear first.",
301
+ );
302
+ });
303
+
304
+ test("throws error when config file contains invalid JSON", async () => {
305
+ writeFileSync(CONFIG_PATH, "invalid json {");
306
+
307
+ const { loadLinearConfig } = await import("../linear/config.js");
308
+
309
+ expect(() => loadLinearConfig()).toThrow();
310
+ });
311
+
312
+ test("throws error when config is missing required fields", async () => {
313
+ writeFileSync(
314
+ CONFIG_PATH,
315
+ JSON.stringify({
316
+ apiKey: "lin_api_test123",
317
+ // missing teamId and projectId
318
+ }),
319
+ );
320
+
321
+ const { loadLinearConfig } = await import("../linear/config.js");
322
+
323
+ expect(() => loadLinearConfig()).toThrow(
324
+ "Invalid Linear config: teamId is required",
325
+ );
326
+ });
327
+ });
328
+
329
+ describe("saveLinearConfig", () => {
330
+ test("saves valid config to file", async () => {
331
+ const config = {
332
+ apiKey: "lin_api_test123",
333
+ teamId: "TEAM-123",
334
+ projectId: "proj_abc123",
335
+ defaultLabels: {
336
+ prd: "prd",
337
+ epic: "epic",
338
+ task: "task",
339
+ },
340
+ };
341
+
342
+ const { saveLinearConfig } = await import("../linear/config.js");
343
+ saveLinearConfig(config);
344
+
345
+ expect(existsSync(CONFIG_PATH)).toBe(true);
346
+
347
+ const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
348
+ expect(saved.apiKey).toBe("lin_api_test123");
349
+ expect(saved.teamId).toBe("TEAM-123");
350
+ expect(saved.projectId).toBe("proj_abc123");
351
+ expect(saved.defaultLabels.prd).toBe("prd");
352
+ expect(saved.defaultLabels.epic).toBe("epic");
353
+ });
354
+
355
+ test("creates .flux directory if it does not exist", async () => {
356
+ // Remove .flux directory
357
+ rmSync(FLUX_DIR, { recursive: true });
358
+
359
+ const config = {
360
+ apiKey: "lin_api_test123",
361
+ teamId: "TEAM-123",
362
+ projectId: "proj_abc123",
363
+ defaultLabels: {
364
+ prd: "prd",
365
+ epic: "epic",
366
+ task: "task",
367
+ },
368
+ };
369
+
370
+ const { saveLinearConfig } = await import("../linear/config.js");
371
+ saveLinearConfig(config);
372
+
373
+ expect(existsSync(FLUX_DIR)).toBe(true);
374
+ expect(existsSync(CONFIG_PATH)).toBe(true);
375
+ });
376
+
377
+ test("overwrites existing config file", async () => {
378
+ writeFileSync(
379
+ CONFIG_PATH,
380
+ JSON.stringify({
381
+ apiKey: "old_api_key",
382
+ teamId: "OLD-TEAM",
383
+ projectId: "old_proj",
384
+ }),
385
+ );
386
+
387
+ const newConfig = {
388
+ apiKey: "new_api_key",
389
+ teamId: "NEW-TEAM",
390
+ projectId: "new_proj",
391
+ defaultLabels: {
392
+ prd: "custom-prd",
393
+ epic: "custom-epic",
394
+ task: "custom-task",
395
+ },
396
+ };
397
+
398
+ const { saveLinearConfig } = await import("../linear/config.js");
399
+ saveLinearConfig(newConfig);
400
+
401
+ const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
402
+ expect(saved.apiKey).toBe("new_api_key");
403
+ expect(saved.teamId).toBe("NEW-TEAM");
404
+ expect(saved.projectId).toBe("new_proj");
405
+ expect(saved.defaultLabels.prd).toBe("custom-prd");
406
+ expect(saved.defaultLabels.epic).toBe("custom-epic");
407
+ });
408
+
409
+ test("validates config before saving", async () => {
410
+ const invalidConfig = {
411
+ apiKey: "lin_api_test",
412
+ // missing teamId and projectId
413
+ } as any;
414
+
415
+ const { saveLinearConfig } = await import("../linear/config.js");
416
+
417
+ expect(() => saveLinearConfig(invalidConfig)).toThrow(
418
+ "Invalid Linear config: teamId is required",
419
+ );
420
+
421
+ // Ensure file was not created
422
+ expect(existsSync(CONFIG_PATH)).toBe(false);
423
+ });
424
+ });
425
+ });
@@ -0,0 +1,287 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ addCriterionToDescription,
4
+ generateCriteriaId,
5
+ parseCriteriaFromDescription,
6
+ updateCriterionInDescription,
7
+ } from "../linear/helpers/criteria-parser.js";
8
+
9
+ describe("criteria-parser", () => {
10
+ describe("generateCriteriaId", () => {
11
+ test("generates stable hash-based IDs", () => {
12
+ const id1 = generateCriteriaId("User can sign in");
13
+ const id2 = generateCriteriaId("User can sign in");
14
+ expect(id1).toBe(id2);
15
+ });
16
+
17
+ test("generates different IDs for different text", () => {
18
+ const id1 = generateCriteriaId("User can sign in");
19
+ const id2 = generateCriteriaId("User can sign out");
20
+ expect(id1).not.toBe(id2);
21
+ });
22
+
23
+ test("ID format is ac_ prefix with 8 hex chars", () => {
24
+ const id = generateCriteriaId("Test criterion");
25
+ expect(id).toMatch(/^ac_[a-f0-9]{8}$/);
26
+ });
27
+
28
+ test("trims whitespace for consistent IDs", () => {
29
+ const id1 = generateCriteriaId(" Test ");
30
+ const id2 = generateCriteriaId("Test");
31
+ expect(id1).toBe(id2);
32
+ });
33
+ });
34
+
35
+ describe("parseCriteriaFromDescription", () => {
36
+ test("returns empty array for undefined description", () => {
37
+ const result = parseCriteriaFromDescription(undefined);
38
+ expect(result).toEqual([]);
39
+ });
40
+
41
+ test("returns empty array for empty description", () => {
42
+ const result = parseCriteriaFromDescription("");
43
+ expect(result).toEqual([]);
44
+ });
45
+
46
+ test("parses unchecked checkboxes with dash", () => {
47
+ const description = "- [ ] First item\n- [ ] Second item";
48
+ const result = parseCriteriaFromDescription(description);
49
+ expect(result).toHaveLength(2);
50
+ expect(result[0].text).toBe("First item");
51
+ expect(result[0].isMet).toBe(false);
52
+ expect(result[1].text).toBe("Second item");
53
+ expect(result[1].isMet).toBe(false);
54
+ });
55
+
56
+ test("parses checked checkboxes with lowercase x", () => {
57
+ const description = "- [x] Completed item";
58
+ const result = parseCriteriaFromDescription(description);
59
+ expect(result).toHaveLength(1);
60
+ expect(result[0].text).toBe("Completed item");
61
+ expect(result[0].isMet).toBe(true);
62
+ });
63
+
64
+ test("parses checked checkboxes with uppercase X", () => {
65
+ const description = "- [X] Completed item";
66
+ const result = parseCriteriaFromDescription(description);
67
+ expect(result).toHaveLength(1);
68
+ expect(result[0].isMet).toBe(true);
69
+ });
70
+
71
+ test("parses checkboxes with asterisk", () => {
72
+ const description = "* [ ] Item with asterisk";
73
+ const result = parseCriteriaFromDescription(description);
74
+ expect(result).toHaveLength(1);
75
+ expect(result[0].text).toBe("Item with asterisk");
76
+ });
77
+
78
+ test("handles indented checkboxes", () => {
79
+ const description = " - [ ] Indented item";
80
+ const result = parseCriteriaFromDescription(description);
81
+ expect(result).toHaveLength(1);
82
+ expect(result[0].text).toBe("Indented item");
83
+ });
84
+
85
+ test("parses only AC section when header is present", () => {
86
+ const description = `# Overview
87
+ Some overview text
88
+
89
+ - [ ] Not a criterion
90
+
91
+ ## Acceptance Criteria
92
+
93
+ - [ ] Real criterion 1
94
+ - [x] Real criterion 2
95
+
96
+ ## Notes
97
+ - [ ] Not a criterion either`;
98
+
99
+ const result = parseCriteriaFromDescription(description);
100
+ expect(result).toHaveLength(2);
101
+ expect(result[0].text).toBe("Real criterion 1");
102
+ expect(result[1].text).toBe("Real criterion 2");
103
+ });
104
+
105
+ test("parses AC section with bold header", () => {
106
+ const description = `**Acceptance Criteria**
107
+
108
+ - [ ] Criterion 1
109
+ - [ ] Criterion 2
110
+
111
+ **Notes**
112
+ - [ ] Not a criterion`;
113
+
114
+ const result = parseCriteriaFromDescription(description);
115
+ expect(result).toHaveLength(2);
116
+ });
117
+
118
+ test("includes line index for updates", () => {
119
+ const description = "Header\n\n- [ ] Item 1\n- [ ] Item 2";
120
+ const result = parseCriteriaFromDescription(description);
121
+ expect(result[0].lineIndex).toBe(2);
122
+ expect(result[1].lineIndex).toBe(3);
123
+ });
124
+
125
+ test("generates correct IDs for criteria", () => {
126
+ const description = "- [ ] Test criterion";
127
+ const result = parseCriteriaFromDescription(description);
128
+ const expectedId = generateCriteriaId("Test criterion");
129
+ expect(result[0].id).toBe(expectedId);
130
+ });
131
+ });
132
+
133
+ describe("updateCriterionInDescription", () => {
134
+ test("marks criterion as met", () => {
135
+ const description = "- [ ] First item\n- [ ] Second item";
136
+ const criteriaId = generateCriteriaId("First item");
137
+ const result = updateCriterionInDescription(
138
+ description,
139
+ criteriaId,
140
+ true,
141
+ );
142
+ expect(result).toBe("- [x] First item\n- [ ] Second item");
143
+ });
144
+
145
+ test("marks criterion as unmet", () => {
146
+ const description = "- [x] First item\n- [ ] Second item";
147
+ const criteriaId = generateCriteriaId("First item");
148
+ const result = updateCriterionInDescription(
149
+ description,
150
+ criteriaId,
151
+ false,
152
+ );
153
+ expect(result).toBe("- [ ] First item\n- [ ] Second item");
154
+ });
155
+
156
+ test("preserves indentation", () => {
157
+ const description = " - [ ] Indented item";
158
+ const criteriaId = generateCriteriaId("Indented item");
159
+ const result = updateCriterionInDescription(
160
+ description,
161
+ criteriaId,
162
+ true,
163
+ );
164
+ expect(result).toBe(" - [x] Indented item");
165
+ });
166
+
167
+ test("throws for non-existent criterion", () => {
168
+ const description = "- [ ] Item";
169
+ expect(() =>
170
+ updateCriterionInDescription(description, "ac_nonexist", true),
171
+ ).toThrow("Criterion not found");
172
+ });
173
+
174
+ test("updates correct criterion when multiple exist", () => {
175
+ const description = "- [ ] First\n- [ ] Second\n- [ ] Third";
176
+ const criteriaId = generateCriteriaId("Second");
177
+ const result = updateCriterionInDescription(
178
+ description,
179
+ criteriaId,
180
+ true,
181
+ );
182
+ expect(result).toBe("- [ ] First\n- [x] Second\n- [ ] Third");
183
+ });
184
+ });
185
+
186
+ describe("addCriterionToDescription", () => {
187
+ test("creates AC section for undefined description", () => {
188
+ const result = addCriterionToDescription(undefined, "New criterion");
189
+ expect(result).toBe("## Acceptance Criteria\n\n- [ ] New criterion");
190
+ });
191
+
192
+ test("creates AC section for empty description", () => {
193
+ const result = addCriterionToDescription("", "New criterion");
194
+ expect(result).toContain("## Acceptance Criteria");
195
+ expect(result).toContain("- [ ] New criterion");
196
+ });
197
+
198
+ test("adds to existing AC section", () => {
199
+ const description = `## Acceptance Criteria
200
+
201
+ - [ ] Existing criterion`;
202
+
203
+ const result = addCriterionToDescription(description, "New criterion");
204
+ expect(result).toContain("- [ ] Existing criterion");
205
+ expect(result).toContain("- [ ] New criterion");
206
+ });
207
+
208
+ test("inserts after last checkbox in AC section", () => {
209
+ const description = `## Acceptance Criteria
210
+
211
+ - [ ] First
212
+ - [ ] Second
213
+
214
+ ## Notes
215
+ Some notes`;
216
+
217
+ const result = addCriterionToDescription(description, "Third");
218
+ const lines = result.split("\n");
219
+ const thirdIndex = lines.findIndex((l) => l.includes("Third"));
220
+ const notesIndex = lines.findIndex((l) => l.includes("## Notes"));
221
+ expect(thirdIndex).toBeLessThan(notesIndex);
222
+ });
223
+
224
+ test("preserves description content before AC section", () => {
225
+ const description = `# Overview
226
+
227
+ This is an overview.
228
+
229
+ ## Acceptance Criteria
230
+
231
+ - [ ] Existing`;
232
+
233
+ const result = addCriterionToDescription(description, "New");
234
+ expect(result).toContain("# Overview");
235
+ expect(result).toContain("This is an overview.");
236
+ });
237
+
238
+ test("creates AC section at end if none exists", () => {
239
+ const description = `# Overview
240
+
241
+ Some content here.`;
242
+
243
+ const result = addCriterionToDescription(description, "New criterion");
244
+ expect(result).toContain("# Overview");
245
+ expect(result).toContain("Some content here.");
246
+ expect(result).toContain("## Acceptance Criteria");
247
+ expect(result).toContain("- [ ] New criterion");
248
+ });
249
+ });
250
+
251
+ describe("integration: parse, update, parse cycle", () => {
252
+ test("round-trip preserves structure", () => {
253
+ const original = `## Acceptance Criteria
254
+
255
+ - [ ] User can login
256
+ - [ ] User can logout
257
+ - [ ] Session persists`;
258
+
259
+ const parsed = parseCriteriaFromDescription(original);
260
+ expect(parsed).toHaveLength(3);
261
+
262
+ const updated = updateCriterionInDescription(
263
+ original,
264
+ parsed[1].id,
265
+ true,
266
+ );
267
+ const reParsed = parseCriteriaFromDescription(updated);
268
+
269
+ expect(reParsed).toHaveLength(3);
270
+ expect(reParsed[0].isMet).toBe(false);
271
+ expect(reParsed[1].isMet).toBe(true);
272
+ expect(reParsed[2].isMet).toBe(false);
273
+ });
274
+
275
+ test("add and parse cycle", () => {
276
+ let description = "## Overview\n\nSome text";
277
+
278
+ description = addCriterionToDescription(description, "First");
279
+ description = addCriterionToDescription(description, "Second");
280
+
281
+ const parsed = parseCriteriaFromDescription(description);
282
+ expect(parsed).toHaveLength(2);
283
+ expect(parsed.map((c) => c.text)).toContain("First");
284
+ expect(parsed.map((c) => c.text)).toContain("Second");
285
+ });
286
+ });
287
+ });