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