opencode-snippets 1.2.0 → 1.4.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.
- package/README.md +73 -13
- package/index.ts +20 -2
- package/package.json +4 -2
- package/src/commands.ts +335 -0
- package/src/constants.ts +2 -7
- package/src/expander.test.ts +468 -86
- package/src/expander.ts +167 -9
- package/src/loader.test.ts +25 -25
- package/src/loader.ts +154 -30
- package/src/logger.ts +2 -2
- package/src/notification.ts +29 -0
- package/src/types.ts +38 -2
package/src/expander.test.ts
CHANGED
|
@@ -1,74 +1,84 @@
|
|
|
1
|
-
import { expandHashtags } from "../src/expander.js";
|
|
2
|
-
import type { SnippetRegistry } from "../src/types.js";
|
|
1
|
+
import { assembleMessage, expandHashtags, parseSnippetBlocks } from "../src/expander.js";
|
|
2
|
+
import type { SnippetInfo, SnippetRegistry } from "../src/types.js";
|
|
3
|
+
|
|
4
|
+
/** Helper to create a SnippetInfo from just content */
|
|
5
|
+
function snippet(content: string, name = "test"): SnippetInfo {
|
|
6
|
+
return { name, content, aliases: [], filePath: "", source: "global" };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Helper to create a registry from [key, content] pairs */
|
|
10
|
+
function createRegistry(entries: [string, string][]): SnippetRegistry {
|
|
11
|
+
return new Map(entries.map(([key, content]) => [key, snippet(content, key)]));
|
|
12
|
+
}
|
|
3
13
|
|
|
4
14
|
describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
5
15
|
describe("Basic expansion", () => {
|
|
6
16
|
it("should expand a single hashtag", () => {
|
|
7
|
-
const registry
|
|
17
|
+
const registry = createRegistry([["greeting", "Hello, World!"]]);
|
|
8
18
|
|
|
9
19
|
const result = expandHashtags("Say #greeting", registry);
|
|
10
20
|
|
|
11
|
-
expect(result).toBe("Say Hello, World!");
|
|
21
|
+
expect(result.text).toBe("Say Hello, World!");
|
|
12
22
|
});
|
|
13
23
|
|
|
14
24
|
it("should expand multiple hashtags in one text", () => {
|
|
15
|
-
const registry
|
|
25
|
+
const registry = createRegistry([
|
|
16
26
|
["greeting", "Hello"],
|
|
17
27
|
["name", "Alice"],
|
|
18
28
|
]);
|
|
19
29
|
|
|
20
30
|
const result = expandHashtags("#greeting, #name!", registry);
|
|
21
31
|
|
|
22
|
-
expect(result).toBe("Hello, Alice!");
|
|
32
|
+
expect(result.text).toBe("Hello, Alice!");
|
|
23
33
|
});
|
|
24
34
|
|
|
25
35
|
it("should leave unknown hashtags unchanged", () => {
|
|
26
|
-
const registry
|
|
36
|
+
const registry = createRegistry([["known", "content"]]);
|
|
27
37
|
|
|
28
38
|
const result = expandHashtags("This is #known and #unknown", registry);
|
|
29
39
|
|
|
30
|
-
expect(result).toBe("This is content and #unknown");
|
|
40
|
+
expect(result.text).toBe("This is content and #unknown");
|
|
31
41
|
});
|
|
32
42
|
|
|
33
43
|
it("should handle empty text", () => {
|
|
34
|
-
const registry
|
|
44
|
+
const registry = createRegistry([["test", "content"]]);
|
|
35
45
|
|
|
36
46
|
const result = expandHashtags("", registry);
|
|
37
47
|
|
|
38
|
-
expect(result).toBe("");
|
|
48
|
+
expect(result.text).toBe("");
|
|
39
49
|
});
|
|
40
50
|
|
|
41
51
|
it("should handle text with no hashtags", () => {
|
|
42
|
-
const registry
|
|
52
|
+
const registry = createRegistry([["test", "content"]]);
|
|
43
53
|
|
|
44
54
|
const result = expandHashtags("No hashtags here", registry);
|
|
45
55
|
|
|
46
|
-
expect(result).toBe("No hashtags here");
|
|
56
|
+
expect(result.text).toBe("No hashtags here");
|
|
47
57
|
});
|
|
48
58
|
|
|
49
59
|
it("should handle case-insensitive hashtags", () => {
|
|
50
|
-
const registry
|
|
60
|
+
const registry = createRegistry([["greeting", "Hello"]]);
|
|
51
61
|
|
|
52
62
|
const result = expandHashtags("#Greeting #GREETING #greeting", registry);
|
|
53
63
|
|
|
54
|
-
expect(result).toBe("Hello Hello Hello");
|
|
64
|
+
expect(result.text).toBe("Hello Hello Hello");
|
|
55
65
|
});
|
|
56
66
|
});
|
|
57
67
|
|
|
58
68
|
describe("Recursive expansion", () => {
|
|
59
69
|
it("should expand nested hashtags one level deep", () => {
|
|
60
|
-
const registry
|
|
70
|
+
const registry = createRegistry([
|
|
61
71
|
["outer", "Start #inner End"],
|
|
62
72
|
["inner", "Middle"],
|
|
63
73
|
]);
|
|
64
74
|
|
|
65
75
|
const result = expandHashtags("#outer", registry);
|
|
66
76
|
|
|
67
|
-
expect(result).toBe("Start Middle End");
|
|
77
|
+
expect(result.text).toBe("Start Middle End");
|
|
68
78
|
});
|
|
69
79
|
|
|
70
80
|
it("should expand nested hashtags multiple levels deep", () => {
|
|
71
|
-
const registry
|
|
81
|
+
const registry = createRegistry([
|
|
72
82
|
["level1", "L1 #level2"],
|
|
73
83
|
["level2", "L2 #level3"],
|
|
74
84
|
["level3", "L3 #level4"],
|
|
@@ -77,11 +87,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
77
87
|
|
|
78
88
|
const result = expandHashtags("#level1", registry);
|
|
79
89
|
|
|
80
|
-
expect(result).toBe("L1 L2 L3 L4");
|
|
90
|
+
expect(result.text).toBe("L1 L2 L3 L4");
|
|
81
91
|
});
|
|
82
92
|
|
|
83
93
|
it("should expand multiple nested hashtags in one snippet", () => {
|
|
84
|
-
const registry
|
|
94
|
+
const registry = createRegistry([
|
|
85
95
|
["main", "Start #a and #b End"],
|
|
86
96
|
["a", "Content A"],
|
|
87
97
|
["b", "Content B"],
|
|
@@ -89,11 +99,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
89
99
|
|
|
90
100
|
const result = expandHashtags("#main", registry);
|
|
91
101
|
|
|
92
|
-
expect(result).toBe("Start Content A and Content B End");
|
|
102
|
+
expect(result.text).toBe("Start Content A and Content B End");
|
|
93
103
|
});
|
|
94
104
|
|
|
95
105
|
it("should expand complex nested structure", () => {
|
|
96
|
-
const registry
|
|
106
|
+
const registry = createRegistry([
|
|
97
107
|
["greeting", "#hello #name"],
|
|
98
108
|
["hello", "Hello"],
|
|
99
109
|
["name", "#firstname #lastname"],
|
|
@@ -103,23 +113,23 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
103
113
|
|
|
104
114
|
const result = expandHashtags("#greeting", registry);
|
|
105
115
|
|
|
106
|
-
expect(result).toBe("Hello John Doe");
|
|
116
|
+
expect(result.text).toBe("Hello John Doe");
|
|
107
117
|
});
|
|
108
118
|
});
|
|
109
119
|
|
|
110
120
|
describe("Loop detection - Direct cycles", () => {
|
|
111
121
|
it("should detect and prevent simple self-reference", { timeout: 100 }, () => {
|
|
112
|
-
const registry
|
|
122
|
+
const registry = createRegistry([["self", "I reference #self"]]);
|
|
113
123
|
|
|
114
124
|
const result = expandHashtags("#self", registry);
|
|
115
125
|
|
|
116
126
|
// Loop detected after 15 expansions, #self left as-is
|
|
117
127
|
const expected = `${"I reference ".repeat(15)}#self`;
|
|
118
|
-
expect(result).toBe(expected);
|
|
128
|
+
expect(result.text).toBe(expected);
|
|
119
129
|
});
|
|
120
130
|
|
|
121
131
|
it("should detect and prevent two-way circular reference", () => {
|
|
122
|
-
const registry
|
|
132
|
+
const registry = createRegistry([
|
|
123
133
|
["a", "A references #b"],
|
|
124
134
|
["b", "B references #a"],
|
|
125
135
|
]);
|
|
@@ -128,11 +138,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
128
138
|
|
|
129
139
|
// Should expand alternating A and B 15 times then stop
|
|
130
140
|
const expected = `${"A references B references ".repeat(15)}#a`;
|
|
131
|
-
expect(result).toBe(expected);
|
|
141
|
+
expect(result.text).toBe(expected);
|
|
132
142
|
});
|
|
133
143
|
|
|
134
144
|
it("should detect and prevent three-way circular reference", () => {
|
|
135
|
-
const registry
|
|
145
|
+
const registry = createRegistry([
|
|
136
146
|
["a", "A -> #b"],
|
|
137
147
|
["b", "B -> #c"],
|
|
138
148
|
["c", "C -> #a"],
|
|
@@ -142,11 +152,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
142
152
|
|
|
143
153
|
// Should expand cycling through A, B, C 15 times then stop
|
|
144
154
|
const expected = `${"A -> B -> C -> ".repeat(15)}#a`;
|
|
145
|
-
expect(result).toBe(expected);
|
|
155
|
+
expect(result.text).toBe(expected);
|
|
146
156
|
});
|
|
147
157
|
|
|
148
158
|
it("should detect loops in longer chains", () => {
|
|
149
|
-
const registry
|
|
159
|
+
const registry = createRegistry([
|
|
150
160
|
["a", "#b"],
|
|
151
161
|
["b", "#c"],
|
|
152
162
|
["c", "#d"],
|
|
@@ -157,13 +167,13 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
157
167
|
const result = expandHashtags("#a", registry);
|
|
158
168
|
|
|
159
169
|
// Should expand until loop detected
|
|
160
|
-
expect(result).toBe("#b");
|
|
170
|
+
expect(result.text).toBe("#b");
|
|
161
171
|
});
|
|
162
172
|
});
|
|
163
173
|
|
|
164
174
|
describe("Loop detection - Complex scenarios", () => {
|
|
165
175
|
it("should allow same snippet in different branches", () => {
|
|
166
|
-
const registry
|
|
176
|
+
const registry = createRegistry([
|
|
167
177
|
["main", "#branch1 and #branch2"],
|
|
168
178
|
["branch1", "B1 uses #shared"],
|
|
169
179
|
["branch2", "B2 uses #shared"],
|
|
@@ -173,11 +183,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
173
183
|
const result = expandHashtags("#main", registry);
|
|
174
184
|
|
|
175
185
|
// #shared should be expanded in both branches
|
|
176
|
-
expect(result).toBe("B1 uses Shared content and B2 uses Shared content");
|
|
186
|
+
expect(result.text).toBe("B1 uses Shared content and B2 uses Shared content");
|
|
177
187
|
});
|
|
178
188
|
|
|
179
189
|
it("should handle partial loops with valid branches", () => {
|
|
180
|
-
const registry
|
|
190
|
+
const registry = createRegistry([
|
|
181
191
|
["main", "#valid and #loop"],
|
|
182
192
|
["valid", "Valid content"],
|
|
183
193
|
["loop", "Loop #loop"],
|
|
@@ -187,11 +197,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
187
197
|
|
|
188
198
|
// Valid expands once, loop expands 15 times
|
|
189
199
|
const expected = `Valid content and ${"Loop ".repeat(15)}#loop`;
|
|
190
|
-
expect(result).toBe(expected);
|
|
200
|
+
expect(result.text).toBe(expected);
|
|
191
201
|
});
|
|
192
202
|
|
|
193
203
|
it("should handle multiple independent loops", () => {
|
|
194
|
-
const registry
|
|
204
|
+
const registry = createRegistry([
|
|
195
205
|
["main", "#loop1 and #loop2"],
|
|
196
206
|
["loop1", "L1 #loop1"],
|
|
197
207
|
["loop2", "L2 #loop2"],
|
|
@@ -201,11 +211,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
201
211
|
|
|
202
212
|
// Each loop expands 15 times independently
|
|
203
213
|
const expected = `${"L1 ".repeat(15)}#loop1 and ${"L2 ".repeat(15)}#loop2`;
|
|
204
|
-
expect(result).toBe(expected);
|
|
214
|
+
expect(result.text).toBe(expected);
|
|
205
215
|
});
|
|
206
216
|
|
|
207
217
|
it("should handle nested loops", () => {
|
|
208
|
-
const registry
|
|
218
|
+
const registry = createRegistry([
|
|
209
219
|
["outer", "Outer #inner"],
|
|
210
220
|
["inner", "Inner #outer and #self"],
|
|
211
221
|
["self", "Self #self"],
|
|
@@ -215,14 +225,14 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
215
225
|
|
|
216
226
|
// Complex nested loop - outer/inner cycle 15 times, plus self cycles
|
|
217
227
|
// This is complex expansion behavior, just verify it doesn't hang
|
|
218
|
-
expect(result).toContain("Outer");
|
|
219
|
-
expect(result).toContain("Inner");
|
|
220
|
-
expect(result).toContain("#outer");
|
|
221
|
-
expect(result).toContain("#self");
|
|
228
|
+
expect(result.text).toContain("Outer");
|
|
229
|
+
expect(result.text).toContain("Inner");
|
|
230
|
+
expect(result.text).toContain("#outer");
|
|
231
|
+
expect(result.text).toContain("#self");
|
|
222
232
|
});
|
|
223
233
|
|
|
224
234
|
it("should handle diamond pattern (same snippet reached via multiple paths)", () => {
|
|
225
|
-
const registry
|
|
235
|
+
const registry = createRegistry([
|
|
226
236
|
["top", "#left #right"],
|
|
227
237
|
["left", "Left #bottom"],
|
|
228
238
|
["right", "Right #bottom"],
|
|
@@ -232,11 +242,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
232
242
|
const result = expandHashtags("#top", registry);
|
|
233
243
|
|
|
234
244
|
// Diamond: top -> left -> bottom, top -> right -> bottom
|
|
235
|
-
expect(result).toBe("Left Bottom Right Bottom");
|
|
245
|
+
expect(result.text).toBe("Left Bottom Right Bottom");
|
|
236
246
|
});
|
|
237
247
|
|
|
238
248
|
it("should handle loop after valid expansion", () => {
|
|
239
|
-
const registry
|
|
249
|
+
const registry = createRegistry([
|
|
240
250
|
["a", "#b #c"],
|
|
241
251
|
["b", "Valid B"],
|
|
242
252
|
["c", "#d"],
|
|
@@ -245,7 +255,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
245
255
|
|
|
246
256
|
const result = expandHashtags("#a", registry);
|
|
247
257
|
|
|
248
|
-
expect(result).toBe("Valid B #c");
|
|
258
|
+
expect(result.text).toBe("Valid B #c");
|
|
249
259
|
});
|
|
250
260
|
});
|
|
251
261
|
|
|
@@ -255,19 +265,19 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
255
265
|
|
|
256
266
|
const result = expandHashtags("#anything", registry);
|
|
257
267
|
|
|
258
|
-
expect(result).toBe("#anything");
|
|
268
|
+
expect(result.text).toBe("#anything");
|
|
259
269
|
});
|
|
260
270
|
|
|
261
271
|
it("should handle snippet with empty content", () => {
|
|
262
|
-
const registry
|
|
272
|
+
const registry = createRegistry([["empty", ""]]);
|
|
263
273
|
|
|
264
274
|
const result = expandHashtags("Before #empty After", registry);
|
|
265
275
|
|
|
266
|
-
expect(result).toBe("Before After");
|
|
276
|
+
expect(result.text).toBe("Before After");
|
|
267
277
|
});
|
|
268
278
|
|
|
269
279
|
it("should handle snippet containing only hashtags", () => {
|
|
270
|
-
const registry
|
|
280
|
+
const registry = createRegistry([
|
|
271
281
|
["only-refs", "#a #b"],
|
|
272
282
|
["a", "A"],
|
|
273
283
|
["b", "B"],
|
|
@@ -275,11 +285,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
275
285
|
|
|
276
286
|
const result = expandHashtags("#only-refs", registry);
|
|
277
287
|
|
|
278
|
-
expect(result).toBe("A B");
|
|
288
|
+
expect(result.text).toBe("A B");
|
|
279
289
|
});
|
|
280
290
|
|
|
281
291
|
it("should handle hashtags at start, middle, and end", () => {
|
|
282
|
-
const registry
|
|
292
|
+
const registry = createRegistry([
|
|
283
293
|
["start", "Start"],
|
|
284
294
|
["middle", "Middle"],
|
|
285
295
|
["end", "End"],
|
|
@@ -287,11 +297,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
287
297
|
|
|
288
298
|
const result = expandHashtags("#start text #middle text #end", registry);
|
|
289
299
|
|
|
290
|
-
expect(result).toBe("Start text Middle text End");
|
|
300
|
+
expect(result.text).toBe("Start text Middle text End");
|
|
291
301
|
});
|
|
292
302
|
|
|
293
303
|
it("should handle consecutive hashtags", () => {
|
|
294
|
-
const registry
|
|
304
|
+
const registry = createRegistry([
|
|
295
305
|
["a", "A"],
|
|
296
306
|
["b", "B"],
|
|
297
307
|
["c", "C"],
|
|
@@ -299,11 +309,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
299
309
|
|
|
300
310
|
const result = expandHashtags("#a#b#c", registry);
|
|
301
311
|
|
|
302
|
-
expect(result).toBe("ABC");
|
|
312
|
+
expect(result.text).toBe("ABC");
|
|
303
313
|
});
|
|
304
314
|
|
|
305
315
|
it("should handle hashtags with hyphens and underscores", () => {
|
|
306
|
-
const registry
|
|
316
|
+
const registry = createRegistry([
|
|
307
317
|
["my-snippet", "Hyphenated"],
|
|
308
318
|
["my_snippet", "Underscored"],
|
|
309
319
|
["my-complex_name", "Mixed"],
|
|
@@ -311,53 +321,53 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
311
321
|
|
|
312
322
|
const result = expandHashtags("#my-snippet #my_snippet #my-complex_name", registry);
|
|
313
323
|
|
|
314
|
-
expect(result).toBe("Hyphenated Underscored Mixed");
|
|
324
|
+
expect(result.text).toBe("Hyphenated Underscored Mixed");
|
|
315
325
|
});
|
|
316
326
|
|
|
317
327
|
it("should handle hashtags with numbers", () => {
|
|
318
|
-
const registry
|
|
328
|
+
const registry = createRegistry([
|
|
319
329
|
["test123", "Test with numbers"],
|
|
320
330
|
["123test", "Numbers first"],
|
|
321
331
|
]);
|
|
322
332
|
|
|
323
333
|
const result = expandHashtags("#test123 #123test", registry);
|
|
324
334
|
|
|
325
|
-
expect(result).toBe("Test with numbers Numbers first");
|
|
335
|
+
expect(result.text).toBe("Test with numbers Numbers first");
|
|
326
336
|
});
|
|
327
337
|
|
|
328
338
|
it("should not expand hashtags in URLs", () => {
|
|
329
|
-
const registry
|
|
339
|
+
const registry = createRegistry([["issue", "ISSUE"]]);
|
|
330
340
|
|
|
331
341
|
// Note: The current implementation WILL expand #issue in URLs
|
|
332
342
|
// This test documents current behavior
|
|
333
343
|
const result = expandHashtags("See https://github.com/user/repo/issues/#issue", registry);
|
|
334
344
|
|
|
335
|
-
expect(result).toBe("See https://github.com/user/repo/issues/ISSUE");
|
|
345
|
+
expect(result.text).toBe("See https://github.com/user/repo/issues/ISSUE");
|
|
336
346
|
});
|
|
337
347
|
|
|
338
348
|
it("should handle multiline content", () => {
|
|
339
|
-
const registry
|
|
349
|
+
const registry = createRegistry([["multiline", "Line 1\nLine 2\nLine 3"]]);
|
|
340
350
|
|
|
341
351
|
const result = expandHashtags("Start\n#multiline\nEnd", registry);
|
|
342
352
|
|
|
343
|
-
expect(result).toBe("Start\nLine 1\nLine 2\nLine 3\nEnd");
|
|
353
|
+
expect(result.text).toBe("Start\nLine 1\nLine 2\nLine 3\nEnd");
|
|
344
354
|
});
|
|
345
355
|
|
|
346
356
|
it("should handle nested multiline content", () => {
|
|
347
|
-
const registry
|
|
357
|
+
const registry = createRegistry([
|
|
348
358
|
["outer", "Outer start\n#inner\nOuter end"],
|
|
349
359
|
["inner", "Inner line 1\nInner line 2"],
|
|
350
360
|
]);
|
|
351
361
|
|
|
352
362
|
const result = expandHashtags("#outer", registry);
|
|
353
363
|
|
|
354
|
-
expect(result).toBe("Outer start\nInner line 1\nInner line 2\nOuter end");
|
|
364
|
+
expect(result.text).toBe("Outer start\nInner line 1\nInner line 2\nOuter end");
|
|
355
365
|
});
|
|
356
366
|
});
|
|
357
367
|
|
|
358
368
|
describe("Real-world scenarios", () => {
|
|
359
369
|
it("should expand code review template with nested snippets", () => {
|
|
360
|
-
const registry
|
|
370
|
+
const registry = createRegistry([
|
|
361
371
|
["review", "Code Review Checklist:\n#security\n#performance\n#tests"],
|
|
362
372
|
["security", "- Check for SQL injection\n- Validate input"],
|
|
363
373
|
["performance", "- Check for N+1 queries\n- Review algorithm complexity"],
|
|
@@ -366,14 +376,14 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
366
376
|
|
|
367
377
|
const result = expandHashtags("#review", registry);
|
|
368
378
|
|
|
369
|
-
expect(result).toContain("Code Review Checklist:");
|
|
370
|
-
expect(result).toContain("Check for SQL injection");
|
|
371
|
-
expect(result).toContain("Check for N+1 queries");
|
|
372
|
-
expect(result).toContain("Unit tests present");
|
|
379
|
+
expect(result.text).toContain("Code Review Checklist:");
|
|
380
|
+
expect(result.text).toContain("Check for SQL injection");
|
|
381
|
+
expect(result.text).toContain("Check for N+1 queries");
|
|
382
|
+
expect(result.text).toContain("Unit tests present");
|
|
373
383
|
});
|
|
374
384
|
|
|
375
385
|
it("should expand documentation template with shared components", () => {
|
|
376
|
-
const registry
|
|
386
|
+
const registry = createRegistry([
|
|
377
387
|
["doc", "# Documentation\n#header\n#body\n#footer"],
|
|
378
388
|
["header", "Author: #author\nDate: 2024-01-01"],
|
|
379
389
|
["author", "John Doe"],
|
|
@@ -384,12 +394,12 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
384
394
|
const result = expandHashtags("#doc", registry);
|
|
385
395
|
|
|
386
396
|
// #author should be expanded in both header and footer
|
|
387
|
-
expect(result).toContain("Author: John Doe");
|
|
388
|
-
expect(result).toContain("Contact: John Doe");
|
|
397
|
+
expect(result.text).toContain("Author: John Doe");
|
|
398
|
+
expect(result.text).toContain("Contact: John Doe");
|
|
389
399
|
});
|
|
390
400
|
|
|
391
401
|
it("should handle instruction composition", () => {
|
|
392
|
-
const registry
|
|
402
|
+
const registry = createRegistry([
|
|
393
403
|
["careful", "Think step by step. #verify"],
|
|
394
404
|
["verify", "Double-check your work."],
|
|
395
405
|
["complete", "Be thorough. #careful"],
|
|
@@ -397,7 +407,9 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
397
407
|
|
|
398
408
|
const result = expandHashtags("Instructions: #complete", registry);
|
|
399
409
|
|
|
400
|
-
expect(result).toBe(
|
|
410
|
+
expect(result.text).toBe(
|
|
411
|
+
"Instructions: Be thorough. Think step by step. Double-check your work.",
|
|
412
|
+
);
|
|
401
413
|
});
|
|
402
414
|
});
|
|
403
415
|
|
|
@@ -408,15 +420,15 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
408
420
|
|
|
409
421
|
// Create a chain: level0 -> level1 -> level2 -> ... -> level49 -> "End"
|
|
410
422
|
for (let i = 0; i < depth - 1; i++) {
|
|
411
|
-
registry.set(`level${i}`, `L${i} #level${i + 1}`);
|
|
423
|
+
registry.set(`level${i}`, snippet(`L${i} #level${i + 1}`, `level${i}`));
|
|
412
424
|
}
|
|
413
|
-
registry.set(`level${depth - 1}`, "End");
|
|
425
|
+
registry.set(`level${depth - 1}`, snippet("End", `level${depth - 1}`));
|
|
414
426
|
|
|
415
427
|
const result = expandHashtags("#level0", registry);
|
|
416
428
|
|
|
417
|
-
expect(result).toContain("L0");
|
|
418
|
-
expect(result).toContain("End");
|
|
419
|
-
expect(result.split(" ").length).toBe(depth);
|
|
429
|
+
expect(result.text).toContain("L0");
|
|
430
|
+
expect(result.text).toContain("End");
|
|
431
|
+
expect(result.text.split(" ").length).toBe(depth);
|
|
420
432
|
});
|
|
421
433
|
|
|
422
434
|
it("should handle many snippets in one text", () => {
|
|
@@ -424,15 +436,15 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
424
436
|
const count = 100;
|
|
425
437
|
|
|
426
438
|
for (let i = 0; i < count; i++) {
|
|
427
|
-
registry.set(`snippet${i}`, `Content${i}`);
|
|
439
|
+
registry.set(`snippet${i}`, snippet(`Content${i}`, `snippet${i}`));
|
|
428
440
|
}
|
|
429
441
|
|
|
430
442
|
const hashtags = Array.from({ length: count }, (_, i) => `#snippet${i}`).join(" ");
|
|
431
443
|
const result = expandHashtags(hashtags, registry);
|
|
432
444
|
|
|
433
|
-
expect(result.split(" ").length).toBe(count);
|
|
434
|
-
expect(result).toContain("Content0");
|
|
435
|
-
expect(result).toContain(`Content${count - 1}`);
|
|
445
|
+
expect(result.text.split(" ").length).toBe(count);
|
|
446
|
+
expect(result.text).toContain("Content0");
|
|
447
|
+
expect(result.text).toContain(`Content${count - 1}`);
|
|
436
448
|
});
|
|
437
449
|
|
|
438
450
|
it("should handle wide branching (many children)", () => {
|
|
@@ -440,17 +452,387 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
|
|
|
440
452
|
const branches = 20;
|
|
441
453
|
|
|
442
454
|
const children = Array.from({ length: branches }, (_, i) => `#child${i}`).join(" ");
|
|
443
|
-
registry.set("parent", children);
|
|
455
|
+
registry.set("parent", snippet(children, "parent"));
|
|
444
456
|
|
|
445
457
|
for (let i = 0; i < branches; i++) {
|
|
446
|
-
registry.set(`child${i}`, `Child${i}`);
|
|
458
|
+
registry.set(`child${i}`, snippet(`Child${i}`, `child${i}`));
|
|
447
459
|
}
|
|
448
460
|
|
|
449
461
|
const result = expandHashtags("#parent", registry);
|
|
450
462
|
|
|
451
463
|
for (let i = 0; i < branches; i++) {
|
|
452
|
-
expect(result).toContain(`Child${i}`);
|
|
464
|
+
expect(result.text).toContain(`Child${i}`);
|
|
453
465
|
}
|
|
454
466
|
});
|
|
455
467
|
});
|
|
456
468
|
});
|
|
469
|
+
|
|
470
|
+
describe("parseSnippetBlocks", () => {
|
|
471
|
+
describe("Basic parsing", () => {
|
|
472
|
+
it("should return full content as inline when no blocks present", () => {
|
|
473
|
+
const result = parseSnippetBlocks("Just some content");
|
|
474
|
+
|
|
475
|
+
expect(result).toEqual({
|
|
476
|
+
inline: "Just some content",
|
|
477
|
+
prepend: [],
|
|
478
|
+
append: [],
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("should extract append block and inline content", () => {
|
|
483
|
+
const result = parseSnippetBlocks("Inline text\n<append>\nAppend content\n</append>");
|
|
484
|
+
|
|
485
|
+
expect(result).toEqual({
|
|
486
|
+
inline: "Inline text",
|
|
487
|
+
prepend: [],
|
|
488
|
+
append: ["Append content"],
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("should extract prepend block and inline content", () => {
|
|
493
|
+
const result = parseSnippetBlocks("<prepend>\nPrepend content\n</prepend>\nInline text");
|
|
494
|
+
|
|
495
|
+
expect(result).toEqual({
|
|
496
|
+
inline: "Inline text",
|
|
497
|
+
prepend: ["Prepend content"],
|
|
498
|
+
append: [],
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("should extract both prepend and append blocks", () => {
|
|
503
|
+
const content = `<prepend>
|
|
504
|
+
Before content
|
|
505
|
+
</prepend>
|
|
506
|
+
Inline text
|
|
507
|
+
<append>
|
|
508
|
+
After content
|
|
509
|
+
</append>`;
|
|
510
|
+
|
|
511
|
+
const result = parseSnippetBlocks(content);
|
|
512
|
+
|
|
513
|
+
expect(result).toEqual({
|
|
514
|
+
inline: "Inline text",
|
|
515
|
+
prepend: ["Before content"],
|
|
516
|
+
append: ["After content"],
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("should handle multiple blocks of the same type", () => {
|
|
521
|
+
const content = `<append>
|
|
522
|
+
First append
|
|
523
|
+
</append>
|
|
524
|
+
Inline
|
|
525
|
+
<append>
|
|
526
|
+
Second append
|
|
527
|
+
</append>`;
|
|
528
|
+
|
|
529
|
+
const result = parseSnippetBlocks(content);
|
|
530
|
+
|
|
531
|
+
expect(result).toEqual({
|
|
532
|
+
inline: "Inline",
|
|
533
|
+
prepend: [],
|
|
534
|
+
append: ["First append", "Second append"],
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
describe("Edge cases", () => {
|
|
540
|
+
it("should handle empty inline (only blocks)", () => {
|
|
541
|
+
const content = `<append>
|
|
542
|
+
Only append content
|
|
543
|
+
</append>`;
|
|
544
|
+
|
|
545
|
+
const result = parseSnippetBlocks(content);
|
|
546
|
+
|
|
547
|
+
expect(result).toEqual({
|
|
548
|
+
inline: "",
|
|
549
|
+
prepend: [],
|
|
550
|
+
append: ["Only append content"],
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("should handle unclosed tag leniently (rest is block content)", () => {
|
|
555
|
+
const content = "Inline\n<append>\nUnclosed append content";
|
|
556
|
+
|
|
557
|
+
const result = parseSnippetBlocks(content);
|
|
558
|
+
|
|
559
|
+
expect(result).toEqual({
|
|
560
|
+
inline: "Inline",
|
|
561
|
+
prepend: [],
|
|
562
|
+
append: ["Unclosed append content"],
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("should return null for nested tags", () => {
|
|
567
|
+
const content = "<append>\n<prepend>\nnested\n</prepend>\n</append>";
|
|
568
|
+
|
|
569
|
+
const result = parseSnippetBlocks(content);
|
|
570
|
+
|
|
571
|
+
expect(result).toBeNull();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("should trim content inside blocks", () => {
|
|
575
|
+
const content = "<append>\n \n Content with whitespace \n \n</append>";
|
|
576
|
+
|
|
577
|
+
const result = parseSnippetBlocks(content);
|
|
578
|
+
|
|
579
|
+
expect(result?.append[0]).toBe("Content with whitespace");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it("should trim inline content", () => {
|
|
583
|
+
const content = " \n Inline with whitespace \n ";
|
|
584
|
+
|
|
585
|
+
const result = parseSnippetBlocks(content);
|
|
586
|
+
|
|
587
|
+
expect(result?.inline).toBe("Inline with whitespace");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("should be case-insensitive for tags", () => {
|
|
591
|
+
const content = "<APPEND>\nContent\n</APPEND>";
|
|
592
|
+
|
|
593
|
+
const result = parseSnippetBlocks(content);
|
|
594
|
+
|
|
595
|
+
expect(result).toEqual({
|
|
596
|
+
inline: "",
|
|
597
|
+
prepend: [],
|
|
598
|
+
append: ["Content"],
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("should handle empty blocks", () => {
|
|
603
|
+
const content = "Inline<append></append>";
|
|
604
|
+
|
|
605
|
+
const result = parseSnippetBlocks(content);
|
|
606
|
+
|
|
607
|
+
expect(result).toEqual({
|
|
608
|
+
inline: "Inline",
|
|
609
|
+
prepend: [],
|
|
610
|
+
append: [],
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe("Real-world content", () => {
|
|
616
|
+
it("should parse Jira MCP example", () => {
|
|
617
|
+
const content = `Jira MCP server
|
|
618
|
+
<append>
|
|
619
|
+
## Jira MCP Usage
|
|
620
|
+
|
|
621
|
+
Use these custom field mappings when creating issues:
|
|
622
|
+
- customfield_16570 => Acceptance Criteria
|
|
623
|
+
- customfield_11401 => Team
|
|
624
|
+
</append>`;
|
|
625
|
+
|
|
626
|
+
const result = parseSnippetBlocks(content);
|
|
627
|
+
|
|
628
|
+
expect(result?.inline).toBe("Jira MCP server");
|
|
629
|
+
expect(result?.append).toHaveLength(1);
|
|
630
|
+
expect(result?.append[0]).toContain("Jira MCP Usage");
|
|
631
|
+
expect(result?.append[0]).toContain("customfield_16570");
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
describe("assembleMessage", () => {
|
|
637
|
+
it("should assemble text only", () => {
|
|
638
|
+
const result = assembleMessage({
|
|
639
|
+
text: "Main content",
|
|
640
|
+
prepend: [],
|
|
641
|
+
append: [],
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
expect(result).toBe("Main content");
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("should assemble with append blocks", () => {
|
|
648
|
+
const result = assembleMessage({
|
|
649
|
+
text: "Main content",
|
|
650
|
+
prepend: [],
|
|
651
|
+
append: ["Appended section"],
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
expect(result).toBe("Main content\n\nAppended section");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("should assemble with prepend blocks", () => {
|
|
658
|
+
const result = assembleMessage({
|
|
659
|
+
text: "Main content",
|
|
660
|
+
prepend: ["Prepended section"],
|
|
661
|
+
append: [],
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
expect(result).toBe("Prepended section\n\nMain content");
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("should assemble with both prepend and append", () => {
|
|
668
|
+
const result = assembleMessage({
|
|
669
|
+
text: "Main content",
|
|
670
|
+
prepend: ["Before"],
|
|
671
|
+
append: ["After"],
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
expect(result).toBe("Before\n\nMain content\n\nAfter");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("should join multiple prepend blocks", () => {
|
|
678
|
+
const result = assembleMessage({
|
|
679
|
+
text: "Main",
|
|
680
|
+
prepend: ["First", "Second"],
|
|
681
|
+
append: [],
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
expect(result).toBe("First\n\nSecond\n\nMain");
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("should join multiple append blocks", () => {
|
|
688
|
+
const result = assembleMessage({
|
|
689
|
+
text: "Main",
|
|
690
|
+
prepend: [],
|
|
691
|
+
append: ["First", "Second"],
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
expect(result).toBe("Main\n\nFirst\n\nSecond");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("should handle empty text with blocks", () => {
|
|
698
|
+
const result = assembleMessage({
|
|
699
|
+
text: "",
|
|
700
|
+
prepend: ["Before"],
|
|
701
|
+
append: ["After"],
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
expect(result).toBe("Before\n\nAfter");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("should handle whitespace-only text with blocks", () => {
|
|
708
|
+
const result = assembleMessage({
|
|
709
|
+
text: " ",
|
|
710
|
+
prepend: ["Before"],
|
|
711
|
+
append: ["After"],
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
expect(result).toBe("Before\n\nAfter");
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
describe("Prepend/Append integration with expandHashtags", () => {
|
|
719
|
+
it("should collect append blocks during expansion", () => {
|
|
720
|
+
const registry = createRegistry([
|
|
721
|
+
["jira", "Jira MCP server\n<append>\nJira reference docs\n</append>"],
|
|
722
|
+
]);
|
|
723
|
+
|
|
724
|
+
const result = expandHashtags("Create a ticket in #jira", registry);
|
|
725
|
+
|
|
726
|
+
expect(result.text).toBe("Create a ticket in Jira MCP server");
|
|
727
|
+
expect(result.append).toEqual(["Jira reference docs"]);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("should collect prepend blocks during expansion", () => {
|
|
731
|
+
const registry = createRegistry([
|
|
732
|
+
["context", "<prepend>\nImportant context\n</prepend>\nUse the context"],
|
|
733
|
+
]);
|
|
734
|
+
|
|
735
|
+
const result = expandHashtags("#context please", registry);
|
|
736
|
+
|
|
737
|
+
expect(result.text).toBe("Use the context please");
|
|
738
|
+
expect(result.prepend).toEqual(["Important context"]);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("should collect blocks from nested snippets", () => {
|
|
742
|
+
const registry = createRegistry([
|
|
743
|
+
["outer", "Outer #inner text"],
|
|
744
|
+
["inner", "Inner\n<append>\nInner's append\n</append>"],
|
|
745
|
+
]);
|
|
746
|
+
|
|
747
|
+
const result = expandHashtags("#outer", registry);
|
|
748
|
+
|
|
749
|
+
expect(result.text).toBe("Outer Inner text");
|
|
750
|
+
expect(result.append).toEqual(["Inner's append"]);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("should collect blocks from multiple snippets", () => {
|
|
754
|
+
const registry = createRegistry([
|
|
755
|
+
["a", "A text\n<append>\nA's append\n</append>"],
|
|
756
|
+
["b", "B text\n<append>\nB's append\n</append>"],
|
|
757
|
+
]);
|
|
758
|
+
|
|
759
|
+
const result = expandHashtags("#a and #b", registry);
|
|
760
|
+
|
|
761
|
+
expect(result.text).toBe("A text and B text");
|
|
762
|
+
expect(result.append).toEqual(["A's append", "B's append"]);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("should handle empty inline with only blocks", () => {
|
|
766
|
+
const registry = createRegistry([["ref", "<append>\nReference material\n</append>"]]);
|
|
767
|
+
|
|
768
|
+
const result = expandHashtags("Use #ref here", registry);
|
|
769
|
+
|
|
770
|
+
expect(result.text).toBe("Use here");
|
|
771
|
+
expect(result.append).toEqual(["Reference material"]);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("should assemble full message correctly", () => {
|
|
775
|
+
const registry = createRegistry([
|
|
776
|
+
["jira", "Jira MCP server\n<append>\n## Jira Usage\n- Field mappings here\n</append>"],
|
|
777
|
+
]);
|
|
778
|
+
|
|
779
|
+
const result = expandHashtags("Create a bug ticket in #jira about the memory leak", registry);
|
|
780
|
+
const assembled = assembleMessage(result);
|
|
781
|
+
|
|
782
|
+
expect(assembled).toBe(
|
|
783
|
+
"Create a bug ticket in Jira MCP server about the memory leak\n\n## Jira Usage\n- Field mappings here",
|
|
784
|
+
);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("should collect multiple append blocks from single snippet", () => {
|
|
788
|
+
const registry = createRegistry([
|
|
789
|
+
["multi", "Inline\n<append>\nFirst append\n</append>\n<append>\nSecond append\n</append>"],
|
|
790
|
+
]);
|
|
791
|
+
|
|
792
|
+
const result = expandHashtags("#multi", registry);
|
|
793
|
+
|
|
794
|
+
expect(result.text).toBe("Inline");
|
|
795
|
+
expect(result.append).toEqual(["First append", "Second append"]);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it("should collect multiple prepend blocks from single snippet", () => {
|
|
799
|
+
const registry = createRegistry([
|
|
800
|
+
[
|
|
801
|
+
"multi",
|
|
802
|
+
"<prepend>\nFirst prepend\n</prepend>\n<prepend>\nSecond prepend\n</prepend>\nInline",
|
|
803
|
+
],
|
|
804
|
+
]);
|
|
805
|
+
|
|
806
|
+
const result = expandHashtags("#multi", registry);
|
|
807
|
+
|
|
808
|
+
expect(result.text).toBe("Inline");
|
|
809
|
+
expect(result.prepend).toEqual(["First prepend", "Second prepend"]);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("should assemble multiple prepends and appends in correct order", () => {
|
|
813
|
+
const registry = createRegistry([
|
|
814
|
+
["a", "<prepend>\nA prepend\n</prepend>\nA inline\n<append>\nA append\n</append>"],
|
|
815
|
+
["b", "<prepend>\nB prepend\n</prepend>\nB inline\n<append>\nB append\n</append>"],
|
|
816
|
+
]);
|
|
817
|
+
|
|
818
|
+
const result = expandHashtags("#a then #b", registry);
|
|
819
|
+
const assembled = assembleMessage(result);
|
|
820
|
+
|
|
821
|
+
// Prepends first (in order), then inline, then appends (in order)
|
|
822
|
+
expect(assembled).toBe(
|
|
823
|
+
"A prepend\n\nB prepend\n\nA inline then B inline\n\nA append\n\nB append",
|
|
824
|
+
);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("should handle mix of snippets with and without blocks", () => {
|
|
828
|
+
const registry = createRegistry([
|
|
829
|
+
["plain", "Plain content"],
|
|
830
|
+
["withblocks", "Block inline\n<append>\nBlock append\n</append>"],
|
|
831
|
+
]);
|
|
832
|
+
|
|
833
|
+
const result = expandHashtags("#plain and #withblocks", registry);
|
|
834
|
+
const assembled = assembleMessage(result);
|
|
835
|
+
|
|
836
|
+
expect(assembled).toBe("Plain content and Block inline\n\nBlock append");
|
|
837
|
+
});
|
|
838
|
+
});
|