@fixy/core 0.0.2 → 0.0.4

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.
@@ -127,18 +127,210 @@ describe('FixyCommandRunner', () => {
127
127
  });
128
128
 
129
129
  // -------------------------------------------------------------------------
130
- // Test 3: /all build somethingreturns stub message
130
+ // Test 3: /all without promptshows usage message
131
131
  // -------------------------------------------------------------------------
132
- it('/all build somethingreturns stub collaboration message', async () => {
132
+ it('/all without promptshows usage message', async () => {
133
+ await runner.run(makeCtx({ rest: '/all' }));
134
+
135
+ const fresh = await store.getThread(thread.id, thread.projectRoot);
136
+ const sysMsg = fresh.messages.find(
137
+ (m) => m.role === 'system' && m.content.includes('/all requires a prompt'),
138
+ );
139
+ expect(sysMsg).toBeDefined();
140
+ });
141
+
142
+ // -------------------------------------------------------------------------
143
+ // Test 3b: /all with no adapters — shows error
144
+ // -------------------------------------------------------------------------
145
+ it('/all with no adapters — shows error', async () => {
133
146
  await runner.run(makeCtx({ rest: '/all build something' }));
134
147
 
135
148
  const fresh = await store.getThread(thread.id, thread.projectRoot);
136
149
  const sysMsg = fresh.messages.find(
137
- (m) => m.role === 'system' && m.content.includes('collaboration engine not yet implemented'),
150
+ (m) => m.role === 'system' && m.content.includes('at least one registered adapter'),
138
151
  );
139
152
  expect(sysMsg).toBeDefined();
140
153
  });
141
154
 
155
+ // -------------------------------------------------------------------------
156
+ // Test 3c: /all solo mode — single adapter skips discussion
157
+ // -------------------------------------------------------------------------
158
+ it('/all solo mode — single adapter runs plan+execute without discussion', async () => {
159
+ let callCount = 0;
160
+ const soloAdapter = createStubAdapter('claude', 'Claude', async (ctx) => {
161
+ callCount++;
162
+ // First call: plan breakdown → return numbered list
163
+ if (callCount === 1) {
164
+ return {
165
+ exitCode: 0, signal: null, timedOut: false,
166
+ summary: '1. Create auth module\n2. Add login endpoint',
167
+ session: null, patches: [], warnings: [], errorMessage: null,
168
+ };
169
+ }
170
+ // Second call: worker execution
171
+ return {
172
+ exitCode: 0, signal: null, timedOut: false,
173
+ summary: 'Implemented auth module and login endpoint',
174
+ session: null, patches: [], warnings: [], errorMessage: null,
175
+ };
176
+ });
177
+ registry.register(soloAdapter);
178
+ thread.workerModel = 'claude';
179
+
180
+ const logs: string[] = [];
181
+ await runner.run(makeCtx({
182
+ rest: '/all build auth',
183
+ onLog: (_s, msg) => logs.push(msg),
184
+ }));
185
+
186
+ expect(logs.some((l) => l.includes('Solo mode'))).toBe(true);
187
+ expect(logs.some((l) => l.includes('Phase 2'))).toBe(true);
188
+ expect(logs.some((l) => l.includes('Phase 3'))).toBe(true);
189
+
190
+ const fresh = await store.getThread(thread.id, thread.projectRoot);
191
+ const completionMsg = fresh.messages.find(
192
+ (m) => m.role === 'system' && m.content.includes('collaboration complete'),
193
+ );
194
+ expect(completionMsg).toBeDefined();
195
+ });
196
+
197
+ // -------------------------------------------------------------------------
198
+ // Test 3d: /all multi-adapter — full discussion + worker + review
199
+ // -------------------------------------------------------------------------
200
+ it('/all multi-adapter — runs discussion, plan, worker execution, and review', async () => {
201
+ // Thinker agrees immediately
202
+ const thinkerAdapter = createStubAdapter('codex', 'Codex', async () => ({
203
+ exitCode: 0, signal: null, timedOut: false,
204
+ summary: 'I agree with the plan. LGTM.\n1. Create util function\n2. Add tests',
205
+ session: null, patches: [], warnings: [], errorMessage: null,
206
+ }));
207
+
208
+ let workerCalls = 0;
209
+ const workerAdapterObj = createStubAdapter('claude', 'Claude', async () => {
210
+ workerCalls++;
211
+ return {
212
+ exitCode: 0, signal: null, timedOut: false,
213
+ summary: 'Done implementing the requested changes.',
214
+ session: null, patches: [], warnings: [], errorMessage: null,
215
+ };
216
+ });
217
+
218
+ registry.register(workerAdapterObj);
219
+ registry.register(thinkerAdapter);
220
+ thread.workerModel = 'claude';
221
+
222
+ const logs: string[] = [];
223
+ await runner.run(makeCtx({
224
+ rest: '/all build a utility',
225
+ onLog: (_s, msg) => logs.push(msg),
226
+ }));
227
+
228
+ // Discussion should have ended early due to agreement
229
+ expect(logs.some((l) => l.includes('Phase 1'))).toBe(true);
230
+ expect(logs.some((l) => l.includes('Phase 2'))).toBe(true);
231
+ expect(logs.some((l) => l.includes('Phase 3'))).toBe(true);
232
+ expect(logs.some((l) => l.includes('Phase 5'))).toBe(true);
233
+ expect(workerCalls).toBeGreaterThanOrEqual(1);
234
+ });
235
+
236
+ // -------------------------------------------------------------------------
237
+ // Test 3e: /all review finds issues — worker gets fix attempt
238
+ // -------------------------------------------------------------------------
239
+ it('/all review issues — worker retries when thinker flags issues', async () => {
240
+ let thinkerCalls = 0;
241
+ const thinkerAdapter = createStubAdapter('codex', 'Codex', async () => {
242
+ thinkerCalls++;
243
+ // Discussion: agree immediately
244
+ if (thinkerCalls <= 1) {
245
+ return {
246
+ exitCode: 0, signal: null, timedOut: false,
247
+ summary: 'I agree.\n1. Fix the bug',
248
+ session: null, patches: [], warnings: [], errorMessage: null,
249
+ };
250
+ }
251
+ // Plan breakdown
252
+ if (thinkerCalls === 2) {
253
+ return {
254
+ exitCode: 0, signal: null, timedOut: false,
255
+ summary: '1. Fix the bug',
256
+ session: null, patches: [], warnings: [], errorMessage: null,
257
+ };
258
+ }
259
+ // First review: flag issue
260
+ if (thinkerCalls === 3) {
261
+ return {
262
+ exitCode: 0, signal: null, timedOut: false,
263
+ summary: 'ISSUES: Missing error handling',
264
+ session: null, patches: [], warnings: [], errorMessage: null,
265
+ };
266
+ }
267
+ // Second review: approve
268
+ return {
269
+ exitCode: 0, signal: null, timedOut: false,
270
+ summary: 'APPROVED',
271
+ session: null, patches: [], warnings: [], errorMessage: null,
272
+ };
273
+ });
274
+
275
+ let workerCalls = 0;
276
+ const workerAdapterObj = createStubAdapter('claude', 'Claude', async () => {
277
+ workerCalls++;
278
+ return {
279
+ exitCode: 0, signal: null, timedOut: false,
280
+ summary: `Worker output attempt ${workerCalls}`,
281
+ session: null, patches: [], warnings: [], errorMessage: null,
282
+ };
283
+ });
284
+
285
+ registry.register(workerAdapterObj);
286
+ registry.register(thinkerAdapter);
287
+ thread.workerModel = 'claude';
288
+
289
+ const logs: string[] = [];
290
+ await runner.run(makeCtx({
291
+ rest: '/all fix the bug',
292
+ onLog: (_s, msg) => logs.push(msg),
293
+ }));
294
+
295
+ // Worker should have been called at least twice (initial + fix)
296
+ expect(workerCalls).toBeGreaterThanOrEqual(2);
297
+ expect(logs.some((l) => l.includes('Phase 4'))).toBe(true);
298
+ });
299
+
300
+ // -------------------------------------------------------------------------
301
+ // Test 3f: /all caps TODOs at 20
302
+ // -------------------------------------------------------------------------
303
+ it('/all caps TODO list at 20 items', async () => {
304
+ const longList = Array.from({ length: 25 }, (_, i) => `${i + 1}. Task item ${i + 1}`).join('\n');
305
+ let callCount = 0;
306
+ const soloAdapter = createStubAdapter('claude', 'Claude', async () => {
307
+ callCount++;
308
+ if (callCount === 1) {
309
+ return {
310
+ exitCode: 0, signal: null, timedOut: false,
311
+ summary: longList,
312
+ session: null, patches: [], warnings: [], errorMessage: null,
313
+ };
314
+ }
315
+ return {
316
+ exitCode: 0, signal: null, timedOut: false,
317
+ summary: 'Done',
318
+ session: null, patches: [], warnings: [], errorMessage: null,
319
+ };
320
+ });
321
+ registry.register(soloAdapter);
322
+ thread.workerModel = 'claude';
323
+
324
+ const logs: string[] = [];
325
+ await runner.run(makeCtx({
326
+ rest: '/all big task',
327
+ onLog: (_s, msg) => logs.push(msg),
328
+ }));
329
+
330
+ // Should report exactly 20 TODOs
331
+ expect(logs.some((l) => l.includes('20 TODO items'))).toBe(true);
332
+ });
333
+
142
334
  // -------------------------------------------------------------------------
143
335
  // Test 4: /settings — returns stub message
144
336
  // -------------------------------------------------------------------------
@@ -0,0 +1,290 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { slugify } from '../slugify.js';
3
+
4
+ describe('slugify — basic ASCII', () => {
5
+ it('lowercases the input', () => {
6
+ expect(slugify('Hello World')).toBe('hello-world');
7
+ });
8
+
9
+ it('removes punctuation', () => {
10
+ expect(slugify('Hello, World!')).toBe('hello-world');
11
+ });
12
+
13
+ it('collapses multiple whitespace characters into a single hyphen', () => {
14
+ expect(slugify('foo bar')).toBe('foo-bar');
15
+ });
16
+
17
+ it('preserves digits', () => {
18
+ expect(slugify('foo 123 bar')).toBe('foo-123-bar');
19
+ });
20
+
21
+ it('collapses repeated non-alphanumeric chars into one hyphen', () => {
22
+ expect(slugify('a---b')).toBe('a-b');
23
+ });
24
+
25
+ it('trims leading hyphens', () => {
26
+ expect(slugify('---hello')).toBe('hello');
27
+ });
28
+
29
+ it('trims trailing hyphens', () => {
30
+ expect(slugify('hello---')).toBe('hello');
31
+ });
32
+
33
+ it('trims both leading and trailing hyphens', () => {
34
+ expect(slugify('---hello---')).toBe('hello');
35
+ });
36
+
37
+ it('returns empty string when only punctuation is given', () => {
38
+ expect(slugify('!!!')).toBe('');
39
+ });
40
+
41
+ it('returns empty string for an empty input', () => {
42
+ expect(slugify('')).toBe('');
43
+ });
44
+
45
+ it('removes apostrophes so contractions collapse without a separator', () => {
46
+ expect(slugify("don't stop")).toBe('dont-stop');
47
+ });
48
+
49
+ it('handles a slug that is already clean', () => {
50
+ expect(slugify('hello-world')).toBe('hello-world');
51
+ });
52
+
53
+ it('handles input that is purely digits', () => {
54
+ expect(slugify('123')).toBe('123');
55
+ });
56
+
57
+ it('output contains only lowercase letters, digits, and hyphens', () => {
58
+ const result = slugify('Hello, World! 123 -- foo@bar.baz');
59
+ expect(result).toMatch(/^[a-z0-9-]+$/);
60
+ });
61
+ });
62
+
63
+ describe('slugify — diacritics and transliteration map', () => {
64
+ it('strips diacritics via NFKD decomposition', () => {
65
+ expect(slugify('naïve café')).toBe('naive-cafe');
66
+ });
67
+
68
+ it('handles "Crème brûlée"', () => {
69
+ expect(slugify('Crème brûlée')).toBe('creme-brulee');
70
+ });
71
+
72
+ it('transliterates ß → ss ("Straße")', () => {
73
+ expect(slugify('Straße')).toBe('strasse');
74
+ });
75
+
76
+ it('transliterates Æ → ae and Œ → oe ("Æther Œuvre")', () => {
77
+ expect(slugify('Æther Œuvre')).toBe('aether-oeuvre');
78
+ });
79
+
80
+ it('transliterates æ → ae', () => {
81
+ expect(slugify('æon')).toBe('aeon');
82
+ });
83
+
84
+ it('transliterates œ → oe', () => {
85
+ expect(slugify('œuvre')).toBe('oeuvre');
86
+ });
87
+
88
+ it('transliterates ø / Ø → o', () => {
89
+ expect(slugify('ø')).toBe('o');
90
+ expect(slugify('Ø')).toBe('o');
91
+ });
92
+
93
+ it('transliterates đ / Đ → d', () => {
94
+ expect(slugify('đuro')).toBe('duro');
95
+ expect(slugify('Đuro')).toBe('duro');
96
+ });
97
+
98
+ it('transliterates ð / Ð → d', () => {
99
+ expect(slugify('ðór')).toBe('dor');
100
+ });
101
+
102
+ it('transliterates þ / Þ → th', () => {
103
+ expect(slugify('þorn')).toBe('thorn');
104
+ expect(slugify('Þorn')).toBe('thorn');
105
+ });
106
+
107
+ it('transliterates ł / Ł → l', () => {
108
+ expect(slugify('Łódź')).toBe('lodz');
109
+ });
110
+
111
+ it('returns empty string when only non-slug-safe unicode chars remain', () => {
112
+ // Characters with no transliteration and no ASCII base (e.g. CJK)
113
+ expect(slugify('日本語')).toBe('');
114
+ });
115
+
116
+ it('output is strictly ASCII for transliterated input', () => {
117
+ const result = slugify('Crème brûlée Straße Æther Œuvre');
118
+ expect(result).toMatch(/^[a-z0-9-]+$/);
119
+ });
120
+ });
121
+
122
+ describe('slugify — separators, underscores, slashes', () => {
123
+ it('converts underscores to hyphens', () => {
124
+ expect(slugify('foo_bar')).toBe('foo-bar');
125
+ });
126
+
127
+ it('converts multiple underscores to a single hyphen', () => {
128
+ expect(slugify('foo___bar')).toBe('foo-bar');
129
+ });
130
+
131
+ it('converts forward slashes to hyphens', () => {
132
+ expect(slugify('foo/bar')).toBe('foo-bar');
133
+ });
134
+
135
+ it('converts backslashes to hyphens', () => {
136
+ expect(slugify('foo\\bar')).toBe('foo-bar');
137
+ });
138
+
139
+ it('converts dots used as separators to hyphens', () => {
140
+ expect(slugify('foo.bar.baz')).toBe('foo-bar-baz');
141
+ });
142
+
143
+ it('converts pipes to hyphens', () => {
144
+ expect(slugify('foo|bar')).toBe('foo-bar');
145
+ });
146
+
147
+ it('converts colons to hyphens', () => {
148
+ expect(slugify('foo:bar')).toBe('foo-bar');
149
+ });
150
+
151
+ it('collapses mixed separators (slash + underscore + space) into one hyphen', () => {
152
+ expect(slugify('foo / _bar')).toBe('foo-bar');
153
+ });
154
+
155
+ it('preserves an already-clean hyphenated slug unchanged', () => {
156
+ expect(slugify('already-clean-slug')).toBe('already-clean-slug');
157
+ });
158
+
159
+ it('preserves hyphenated slug with numbers', () => {
160
+ expect(slugify('step-1-of-3')).toBe('step-1-of-3');
161
+ });
162
+ });
163
+
164
+ describe('slugify — apostrophes', () => {
165
+ it('removes straight apostrophe in contraction without inserting a hyphen', () => {
166
+ expect(slugify("it's")).toBe('its');
167
+ });
168
+
169
+ it('removes Unicode left single quotation mark (U+2018)', () => {
170
+ expect(slugify('\u2018hello\u2019')).toBe('hello');
171
+ });
172
+
173
+ it('removes Unicode right single quotation mark (U+2019)', () => {
174
+ expect(slugify("won\u2019t")).toBe('wont');
175
+ });
176
+
177
+ it('removes modifier letter apostrophe (U+02BC)', () => {
178
+ expect(slugify('o\u02BCclock')).toBe('oclock');
179
+ });
180
+
181
+ it('handles multiple apostrophes in a row without producing hyphens', () => {
182
+ expect(slugify("''hello''")).toBe('hello');
183
+ });
184
+ });
185
+
186
+ describe('slugify — emojis', () => {
187
+ it('strips a leading emoji', () => {
188
+ expect(slugify('🚀 launch')).toBe('launch');
189
+ });
190
+
191
+ it('strips a trailing emoji', () => {
192
+ expect(slugify('launch 🚀')).toBe('launch');
193
+ });
194
+
195
+ it('strips an emoji between words without doubling hyphens', () => {
196
+ expect(slugify('foo 🔥 bar')).toBe('foo-bar');
197
+ });
198
+
199
+ it('strips an emoji adjacent to a word without inserting a hyphen', () => {
200
+ expect(slugify('foo🔥bar')).toBe('foobar');
201
+ });
202
+
203
+ it('returns empty string when input is only emojis', () => {
204
+ expect(slugify('🚀🔥💡')).toBe('');
205
+ });
206
+ });
207
+
208
+ describe('slugify — edge cases', () => {
209
+ it('returns empty string for an empty input ""', () => {
210
+ expect(slugify('')).toBe('');
211
+ });
212
+
213
+ it('returns empty string for whitespace-only input', () => {
214
+ expect(slugify(' ')).toBe('');
215
+ });
216
+
217
+ it('returns empty string for tab and newline input', () => {
218
+ expect(slugify('\t\n\r')).toBe('');
219
+ });
220
+
221
+ it('returns empty string for all-symbol input (mixed punctuation)', () => {
222
+ expect(slugify('!@#$%^&*()')).toBe('');
223
+ });
224
+
225
+ it('returns empty string for Cyrillic input with no transliteration', () => {
226
+ expect(slugify('Привет')).toBe('');
227
+ });
228
+
229
+ it('returns empty string for Arabic input', () => {
230
+ expect(slugify('مرحبا')).toBe('');
231
+ });
232
+
233
+ it('returns empty string for Hebrew input', () => {
234
+ expect(slugify('שלום')).toBe('');
235
+ });
236
+
237
+ it('returns empty string for CJK input', () => {
238
+ expect(slugify('日本語')).toBe('');
239
+ });
240
+
241
+ it('handles a single ASCII letter', () => {
242
+ expect(slugify('a')).toBe('a');
243
+ });
244
+
245
+ it('handles a single digit', () => {
246
+ expect(slugify('9')).toBe('9');
247
+ });
248
+ });
249
+
250
+ describe('slugify — output invariants', () => {
251
+ const cases = [
252
+ 'Hello, World!',
253
+ ' leading and trailing ',
254
+ '---hyphens---',
255
+ 'a---b---c',
256
+ 'foo / bar \\ baz',
257
+ 'foo_bar_baz',
258
+ 'Crème brûlée Straße',
259
+ '🚀 rockets 🔥 fire',
260
+ 'it\u2019s a test',
261
+ '!!!',
262
+ '',
263
+ '日本語',
264
+ 'UPPER CASE INPUT',
265
+ 'mixed123Numbers',
266
+ ];
267
+
268
+ for (const input of cases) {
269
+ it(`output for "${input}" never has repeated, leading, or trailing hyphens`, () => {
270
+ const result = slugify(input);
271
+ if (result.length > 0) {
272
+ expect(result).not.toMatch(/^-/);
273
+ expect(result).not.toMatch(/-$/);
274
+ expect(result).not.toMatch(/--/);
275
+ expect(result).toMatch(/^[a-z0-9-]+$/);
276
+ } else {
277
+ expect(result).toBe('');
278
+ }
279
+ });
280
+ }
281
+
282
+ it('valid slug input is idempotent (slugify(slugify(x)) === slugify(x))', () => {
283
+ const inputs = ['Hello World', 'foo_bar', 'Crème brûlée', '🚀 launch', "don't stop"];
284
+ for (const input of inputs) {
285
+ const once = slugify(input);
286
+ const twice = slugify(once);
287
+ expect(twice).toBe(once);
288
+ }
289
+ });
290
+ });