opencode-snippets 1.1.2 → 1.2.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.
@@ -0,0 +1,456 @@
1
+ import { expandHashtags } from "../src/expander.js";
2
+ import type { SnippetRegistry } from "../src/types.js";
3
+
4
+ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
5
+ describe("Basic expansion", () => {
6
+ it("should expand a single hashtag", () => {
7
+ const registry: SnippetRegistry = new Map([["greeting", "Hello, World!"]]);
8
+
9
+ const result = expandHashtags("Say #greeting", registry);
10
+
11
+ expect(result).toBe("Say Hello, World!");
12
+ });
13
+
14
+ it("should expand multiple hashtags in one text", () => {
15
+ const registry: SnippetRegistry = new Map([
16
+ ["greeting", "Hello"],
17
+ ["name", "Alice"],
18
+ ]);
19
+
20
+ const result = expandHashtags("#greeting, #name!", registry);
21
+
22
+ expect(result).toBe("Hello, Alice!");
23
+ });
24
+
25
+ it("should leave unknown hashtags unchanged", () => {
26
+ const registry: SnippetRegistry = new Map([["known", "content"]]);
27
+
28
+ const result = expandHashtags("This is #known and #unknown", registry);
29
+
30
+ expect(result).toBe("This is content and #unknown");
31
+ });
32
+
33
+ it("should handle empty text", () => {
34
+ const registry: SnippetRegistry = new Map([["test", "content"]]);
35
+
36
+ const result = expandHashtags("", registry);
37
+
38
+ expect(result).toBe("");
39
+ });
40
+
41
+ it("should handle text with no hashtags", () => {
42
+ const registry: SnippetRegistry = new Map([["test", "content"]]);
43
+
44
+ const result = expandHashtags("No hashtags here", registry);
45
+
46
+ expect(result).toBe("No hashtags here");
47
+ });
48
+
49
+ it("should handle case-insensitive hashtags", () => {
50
+ const registry: SnippetRegistry = new Map([["greeting", "Hello"]]);
51
+
52
+ const result = expandHashtags("#Greeting #GREETING #greeting", registry);
53
+
54
+ expect(result).toBe("Hello Hello Hello");
55
+ });
56
+ });
57
+
58
+ describe("Recursive expansion", () => {
59
+ it("should expand nested hashtags one level deep", () => {
60
+ const registry: SnippetRegistry = new Map([
61
+ ["outer", "Start #inner End"],
62
+ ["inner", "Middle"],
63
+ ]);
64
+
65
+ const result = expandHashtags("#outer", registry);
66
+
67
+ expect(result).toBe("Start Middle End");
68
+ });
69
+
70
+ it("should expand nested hashtags multiple levels deep", () => {
71
+ const registry: SnippetRegistry = new Map([
72
+ ["level1", "L1 #level2"],
73
+ ["level2", "L2 #level3"],
74
+ ["level3", "L3 #level4"],
75
+ ["level4", "L4"],
76
+ ]);
77
+
78
+ const result = expandHashtags("#level1", registry);
79
+
80
+ expect(result).toBe("L1 L2 L3 L4");
81
+ });
82
+
83
+ it("should expand multiple nested hashtags in one snippet", () => {
84
+ const registry: SnippetRegistry = new Map([
85
+ ["main", "Start #a and #b End"],
86
+ ["a", "Content A"],
87
+ ["b", "Content B"],
88
+ ]);
89
+
90
+ const result = expandHashtags("#main", registry);
91
+
92
+ expect(result).toBe("Start Content A and Content B End");
93
+ });
94
+
95
+ it("should expand complex nested structure", () => {
96
+ const registry: SnippetRegistry = new Map([
97
+ ["greeting", "#hello #name"],
98
+ ["hello", "Hello"],
99
+ ["name", "#firstname #lastname"],
100
+ ["firstname", "John"],
101
+ ["lastname", "Doe"],
102
+ ]);
103
+
104
+ const result = expandHashtags("#greeting", registry);
105
+
106
+ expect(result).toBe("Hello John Doe");
107
+ });
108
+ });
109
+
110
+ describe("Loop detection - Direct cycles", () => {
111
+ it("should detect and prevent simple self-reference", { timeout: 100 }, () => {
112
+ const registry: SnippetRegistry = new Map([["self", "I reference #self"]]);
113
+
114
+ const result = expandHashtags("#self", registry);
115
+
116
+ // Loop detected after 15 expansions, #self left as-is
117
+ const expected = `${"I reference ".repeat(15)}#self`;
118
+ expect(result).toBe(expected);
119
+ });
120
+
121
+ it("should detect and prevent two-way circular reference", () => {
122
+ const registry: SnippetRegistry = new Map([
123
+ ["a", "A references #b"],
124
+ ["b", "B references #a"],
125
+ ]);
126
+
127
+ const result = expandHashtags("#a", registry);
128
+
129
+ // Should expand alternating A and B 15 times then stop
130
+ const expected = `${"A references B references ".repeat(15)}#a`;
131
+ expect(result).toBe(expected);
132
+ });
133
+
134
+ it("should detect and prevent three-way circular reference", () => {
135
+ const registry: SnippetRegistry = new Map([
136
+ ["a", "A -> #b"],
137
+ ["b", "B -> #c"],
138
+ ["c", "C -> #a"],
139
+ ]);
140
+
141
+ const result = expandHashtags("#a", registry);
142
+
143
+ // Should expand cycling through A, B, C 15 times then stop
144
+ const expected = `${"A -> B -> C -> ".repeat(15)}#a`;
145
+ expect(result).toBe(expected);
146
+ });
147
+
148
+ it("should detect loops in longer chains", () => {
149
+ const registry: SnippetRegistry = new Map([
150
+ ["a", "#b"],
151
+ ["b", "#c"],
152
+ ["c", "#d"],
153
+ ["d", "#e"],
154
+ ["e", "#b"], // Loop back to b
155
+ ]);
156
+
157
+ const result = expandHashtags("#a", registry);
158
+
159
+ // Should expand until loop detected
160
+ expect(result).toBe("#b");
161
+ });
162
+ });
163
+
164
+ describe("Loop detection - Complex scenarios", () => {
165
+ it("should allow same snippet in different branches", () => {
166
+ const registry: SnippetRegistry = new Map([
167
+ ["main", "#branch1 and #branch2"],
168
+ ["branch1", "B1 uses #shared"],
169
+ ["branch2", "B2 uses #shared"],
170
+ ["shared", "Shared content"],
171
+ ]);
172
+
173
+ const result = expandHashtags("#main", registry);
174
+
175
+ // #shared should be expanded in both branches
176
+ expect(result).toBe("B1 uses Shared content and B2 uses Shared content");
177
+ });
178
+
179
+ it("should handle partial loops with valid branches", () => {
180
+ const registry: SnippetRegistry = new Map([
181
+ ["main", "#valid and #loop"],
182
+ ["valid", "Valid content"],
183
+ ["loop", "Loop #loop"],
184
+ ]);
185
+
186
+ const result = expandHashtags("#main", registry);
187
+
188
+ // Valid expands once, loop expands 15 times
189
+ const expected = `Valid content and ${"Loop ".repeat(15)}#loop`;
190
+ expect(result).toBe(expected);
191
+ });
192
+
193
+ it("should handle multiple independent loops", () => {
194
+ const registry: SnippetRegistry = new Map([
195
+ ["main", "#loop1 and #loop2"],
196
+ ["loop1", "L1 #loop1"],
197
+ ["loop2", "L2 #loop2"],
198
+ ]);
199
+
200
+ const result = expandHashtags("#main", registry);
201
+
202
+ // Each loop expands 15 times independently
203
+ const expected = `${"L1 ".repeat(15)}#loop1 and ${"L2 ".repeat(15)}#loop2`;
204
+ expect(result).toBe(expected);
205
+ });
206
+
207
+ it("should handle nested loops", () => {
208
+ const registry: SnippetRegistry = new Map([
209
+ ["outer", "Outer #inner"],
210
+ ["inner", "Inner #outer and #self"],
211
+ ["self", "Self #self"],
212
+ ]);
213
+
214
+ const result = expandHashtags("#outer", registry);
215
+
216
+ // Complex nested loop - outer/inner cycle 15 times, plus self cycles
217
+ // 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");
222
+ });
223
+
224
+ it("should handle diamond pattern (same snippet reached via multiple paths)", () => {
225
+ const registry: SnippetRegistry = new Map([
226
+ ["top", "#left #right"],
227
+ ["left", "Left #bottom"],
228
+ ["right", "Right #bottom"],
229
+ ["bottom", "Bottom"],
230
+ ]);
231
+
232
+ const result = expandHashtags("#top", registry);
233
+
234
+ // Diamond: top -> left -> bottom, top -> right -> bottom
235
+ expect(result).toBe("Left Bottom Right Bottom");
236
+ });
237
+
238
+ it("should handle loop after valid expansion", () => {
239
+ const registry: SnippetRegistry = new Map([
240
+ ["a", "#b #c"],
241
+ ["b", "Valid B"],
242
+ ["c", "#d"],
243
+ ["d", "#c"], // Loop back
244
+ ]);
245
+
246
+ const result = expandHashtags("#a", registry);
247
+
248
+ expect(result).toBe("Valid B #c");
249
+ });
250
+ });
251
+
252
+ describe("Edge cases", () => {
253
+ it("should handle empty registry", () => {
254
+ const registry: SnippetRegistry = new Map();
255
+
256
+ const result = expandHashtags("#anything", registry);
257
+
258
+ expect(result).toBe("#anything");
259
+ });
260
+
261
+ it("should handle snippet with empty content", () => {
262
+ const registry: SnippetRegistry = new Map([["empty", ""]]);
263
+
264
+ const result = expandHashtags("Before #empty After", registry);
265
+
266
+ expect(result).toBe("Before After");
267
+ });
268
+
269
+ it("should handle snippet containing only hashtags", () => {
270
+ const registry: SnippetRegistry = new Map([
271
+ ["only-refs", "#a #b"],
272
+ ["a", "A"],
273
+ ["b", "B"],
274
+ ]);
275
+
276
+ const result = expandHashtags("#only-refs", registry);
277
+
278
+ expect(result).toBe("A B");
279
+ });
280
+
281
+ it("should handle hashtags at start, middle, and end", () => {
282
+ const registry: SnippetRegistry = new Map([
283
+ ["start", "Start"],
284
+ ["middle", "Middle"],
285
+ ["end", "End"],
286
+ ]);
287
+
288
+ const result = expandHashtags("#start text #middle text #end", registry);
289
+
290
+ expect(result).toBe("Start text Middle text End");
291
+ });
292
+
293
+ it("should handle consecutive hashtags", () => {
294
+ const registry: SnippetRegistry = new Map([
295
+ ["a", "A"],
296
+ ["b", "B"],
297
+ ["c", "C"],
298
+ ]);
299
+
300
+ const result = expandHashtags("#a#b#c", registry);
301
+
302
+ expect(result).toBe("ABC");
303
+ });
304
+
305
+ it("should handle hashtags with hyphens and underscores", () => {
306
+ const registry: SnippetRegistry = new Map([
307
+ ["my-snippet", "Hyphenated"],
308
+ ["my_snippet", "Underscored"],
309
+ ["my-complex_name", "Mixed"],
310
+ ]);
311
+
312
+ const result = expandHashtags("#my-snippet #my_snippet #my-complex_name", registry);
313
+
314
+ expect(result).toBe("Hyphenated Underscored Mixed");
315
+ });
316
+
317
+ it("should handle hashtags with numbers", () => {
318
+ const registry: SnippetRegistry = new Map([
319
+ ["test123", "Test with numbers"],
320
+ ["123test", "Numbers first"],
321
+ ]);
322
+
323
+ const result = expandHashtags("#test123 #123test", registry);
324
+
325
+ expect(result).toBe("Test with numbers Numbers first");
326
+ });
327
+
328
+ it("should not expand hashtags in URLs", () => {
329
+ const registry: SnippetRegistry = new Map([["issue", "ISSUE"]]);
330
+
331
+ // Note: The current implementation WILL expand #issue in URLs
332
+ // This test documents current behavior
333
+ const result = expandHashtags("See https://github.com/user/repo/issues/#issue", registry);
334
+
335
+ expect(result).toBe("See https://github.com/user/repo/issues/ISSUE");
336
+ });
337
+
338
+ it("should handle multiline content", () => {
339
+ const registry: SnippetRegistry = new Map([["multiline", "Line 1\nLine 2\nLine 3"]]);
340
+
341
+ const result = expandHashtags("Start\n#multiline\nEnd", registry);
342
+
343
+ expect(result).toBe("Start\nLine 1\nLine 2\nLine 3\nEnd");
344
+ });
345
+
346
+ it("should handle nested multiline content", () => {
347
+ const registry: SnippetRegistry = new Map([
348
+ ["outer", "Outer start\n#inner\nOuter end"],
349
+ ["inner", "Inner line 1\nInner line 2"],
350
+ ]);
351
+
352
+ const result = expandHashtags("#outer", registry);
353
+
354
+ expect(result).toBe("Outer start\nInner line 1\nInner line 2\nOuter end");
355
+ });
356
+ });
357
+
358
+ describe("Real-world scenarios", () => {
359
+ it("should expand code review template with nested snippets", () => {
360
+ const registry: SnippetRegistry = new Map([
361
+ ["review", "Code Review Checklist:\n#security\n#performance\n#tests"],
362
+ ["security", "- Check for SQL injection\n- Validate input"],
363
+ ["performance", "- Check for N+1 queries\n- Review algorithm complexity"],
364
+ ["tests", "- Unit tests present\n- Edge cases covered"],
365
+ ]);
366
+
367
+ const result = expandHashtags("#review", registry);
368
+
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");
373
+ });
374
+
375
+ it("should expand documentation template with shared components", () => {
376
+ const registry: SnippetRegistry = new Map([
377
+ ["doc", "# Documentation\n#header\n#body\n#footer"],
378
+ ["header", "Author: #author\nDate: 2024-01-01"],
379
+ ["author", "John Doe"],
380
+ ["body", "Main content here"],
381
+ ["footer", "Contact: #author"],
382
+ ]);
383
+
384
+ const result = expandHashtags("#doc", registry);
385
+
386
+ // #author should be expanded in both header and footer
387
+ expect(result).toContain("Author: John Doe");
388
+ expect(result).toContain("Contact: John Doe");
389
+ });
390
+
391
+ it("should handle instruction composition", () => {
392
+ const registry: SnippetRegistry = new Map([
393
+ ["careful", "Think step by step. #verify"],
394
+ ["verify", "Double-check your work."],
395
+ ["complete", "Be thorough. #careful"],
396
+ ]);
397
+
398
+ const result = expandHashtags("Instructions: #complete", registry);
399
+
400
+ expect(result).toBe("Instructions: Be thorough. Think step by step. Double-check your work.");
401
+ });
402
+ });
403
+
404
+ describe("Performance and stress tests", () => {
405
+ it("should handle deep nesting without stack overflow", () => {
406
+ const registry: SnippetRegistry = new Map();
407
+ const depth = 50;
408
+
409
+ // Create a chain: level0 -> level1 -> level2 -> ... -> level49 -> "End"
410
+ for (let i = 0; i < depth - 1; i++) {
411
+ registry.set(`level${i}`, `L${i} #level${i + 1}`);
412
+ }
413
+ registry.set(`level${depth - 1}`, "End");
414
+
415
+ const result = expandHashtags("#level0", registry);
416
+
417
+ expect(result).toContain("L0");
418
+ expect(result).toContain("End");
419
+ expect(result.split(" ").length).toBe(depth);
420
+ });
421
+
422
+ it("should handle many snippets in one text", () => {
423
+ const registry: SnippetRegistry = new Map();
424
+ const count = 100;
425
+
426
+ for (let i = 0; i < count; i++) {
427
+ registry.set(`snippet${i}`, `Content${i}`);
428
+ }
429
+
430
+ const hashtags = Array.from({ length: count }, (_, i) => `#snippet${i}`).join(" ");
431
+ const result = expandHashtags(hashtags, registry);
432
+
433
+ expect(result.split(" ").length).toBe(count);
434
+ expect(result).toContain("Content0");
435
+ expect(result).toContain(`Content${count - 1}`);
436
+ });
437
+
438
+ it("should handle wide branching (many children)", () => {
439
+ const registry: SnippetRegistry = new Map();
440
+ const branches = 20;
441
+
442
+ const children = Array.from({ length: branches }, (_, i) => `#child${i}`).join(" ");
443
+ registry.set("parent", children);
444
+
445
+ for (let i = 0; i < branches; i++) {
446
+ registry.set(`child${i}`, `Child${i}`);
447
+ }
448
+
449
+ const result = expandHashtags("#parent", registry);
450
+
451
+ for (let i = 0; i < branches; i++) {
452
+ expect(result).toContain(`Child${i}`);
453
+ }
454
+ });
455
+ });
456
+ });
package/src/expander.ts CHANGED
@@ -1,57 +1,67 @@
1
- import type { SnippetRegistry } from "./types.js"
2
- import { PATTERNS } from "./constants.js"
1
+ import { PATTERNS } from "./constants.js";
2
+ import { logger } from "./logger.js";
3
+ import type { SnippetRegistry } from "./types.js";
4
+
5
+ /**
6
+ * Maximum number of times a snippet can be expanded to prevent infinite loops
7
+ */
8
+ const MAX_EXPANSION_COUNT = 15;
3
9
 
4
10
  /**
5
11
  * Expands hashtags in text recursively with loop detection
6
- *
12
+ *
7
13
  * @param text - The text containing hashtags to expand
8
14
  * @param registry - The snippet registry to look up hashtags
9
- * @param visited - Set of already-visited snippet keys (for loop detection)
15
+ * @param expansionCounts - Map tracking how many times each snippet has been expanded
10
16
  * @returns The text with all hashtags expanded
11
17
  */
12
18
  export function expandHashtags(
13
19
  text: string,
14
20
  registry: SnippetRegistry,
15
- visited = new Set<string>()
21
+ expansionCounts = new Map<string, number>(),
16
22
  ): string {
17
- let expanded = text
18
- let hasChanges = true
19
-
23
+ let expanded = text;
24
+ let hasChanges = true;
25
+
20
26
  // Keep expanding until no more hashtags are found
21
27
  while (hasChanges) {
22
- hasChanges = false
23
-
28
+ const previous = expanded;
29
+ let loopDetected = false;
30
+
24
31
  // Reset regex state (global flag requires this)
25
- PATTERNS.HASHTAG.lastIndex = 0
26
-
32
+ PATTERNS.HASHTAG.lastIndex = 0;
33
+
27
34
  expanded = expanded.replace(PATTERNS.HASHTAG, (match, name) => {
28
- const key = name.toLowerCase()
29
-
30
- // Check if we've already expanded this snippet in the current chain
31
- if (visited.has(key)) {
32
- // Loop detected! Leave the hashtag unchanged to prevent infinite recursion
33
- return match
34
- }
35
-
36
- const content = registry.get(key)
37
- if (!content) {
35
+ const key = name.toLowerCase();
36
+
37
+ const content = registry.get(key);
38
+ if (content === undefined) {
38
39
  // Unknown snippet - leave as-is
39
- return match
40
+ return match;
40
41
  }
41
-
42
- // Mark this snippet as visited and expand it
43
- visited.add(key)
44
- hasChanges = true
45
-
42
+
43
+ // Track expansion count to prevent infinite loops
44
+ const count = (expansionCounts.get(key) || 0) + 1;
45
+ if (count > MAX_EXPANSION_COUNT) {
46
+ // Loop detected! Leave the hashtag as-is and stop expanding
47
+ logger.warn(
48
+ `Loop detected: snippet '#${key}' expanded ${count} times (max: ${MAX_EXPANSION_COUNT})`,
49
+ );
50
+ loopDetected = true;
51
+ return match; // Leave as-is instead of error message
52
+ }
53
+
54
+ expansionCounts.set(key, count);
55
+
46
56
  // Recursively expand any hashtags in the snippet content
47
- const result = expandHashtags(content, registry, new Set(visited))
48
-
49
- // Remove from visited set after expansion (allows reuse in different branches)
50
- visited.delete(key)
51
-
52
- return result
53
- })
57
+ const result = expandHashtags(content, registry, expansionCounts);
58
+
59
+ return result;
60
+ });
61
+
62
+ // Only continue if the text actually changed AND no loop was detected
63
+ hasChanges = expanded !== previous && !loopDetected;
54
64
  }
55
-
56
- return expanded
65
+
66
+ return expanded;
57
67
  }