@@ -4,11 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
44
55const mockReadFile = vi . hoisted ( ( ) => vi . fn ( ) ) ;
66const mockStat = vi . hoisted ( ( ) => vi . fn ( ) ) ;
7+ const mockRealpath = vi . hoisted ( ( ) => vi . fn ( ) ) ;
78
89vi . mock ( "node:fs" , ( ) => {
910 const promises = {
1011 readFile : mockReadFile ,
1112 stat : mockStat ,
13+ realpath : mockRealpath ,
1214 access : vi . fn ( ) ,
1315 writeFile : vi . fn ( ) ,
1416 unlink : vi . fn ( ) ,
@@ -54,6 +56,7 @@ function createService() {
5456
5557beforeEach ( ( ) => {
5658 vi . clearAllMocks ( ) ;
59+ mockRealpath . mockImplementation ( async ( p : string ) => p ) ;
5760} ) ;
5861
5962describe ( "OsService.showMessageBox" , ( ) => {
@@ -253,6 +256,276 @@ describe("OsService.getUserAgentInstructions", () => {
253256 } ) ;
254257} ) ;
255258
259+ describe ( "OsService.getUserAgentInstructions @-import expansion" , ( ) => {
260+ const home = os . homedir ( ) ;
261+ const claudeDir = path . join ( home , ".claude" ) ;
262+ const claudePath = path . join ( claudeDir , "CLAUDE.md" ) ;
263+ const aPath = path . join ( claudeDir , "a.md" ) ;
264+ const bPath = path . join ( claudeDir , "b.md" ) ;
265+ const engineeringPath = path . join ( claudeDir , "engineering.md" ) ;
266+
267+ function givenFiles ( files : Record < string , string > ) {
268+ mockReadFile . mockImplementation ( async ( filePath : string ) => {
269+ if ( filePath in files ) return files [ filePath ] ;
270+ throw new Error ( "ENOENT" ) ;
271+ } ) ;
272+ }
273+
274+ it . each ( [
275+ {
276+ label : "leaves files without imports untouched" ,
277+ files : { [ claudePath ] : "just plain rules\nno imports here" } ,
278+ expected : "just plain rules\nno imports here" ,
279+ } ,
280+ {
281+ label : "inlines a single relative import" ,
282+ files : {
283+ [ claudePath ] : "top rules\n@./engineering.md" ,
284+ [ engineeringPath ] : "engineering rules" ,
285+ } ,
286+ expected : "top rules\nengineering rules" ,
287+ } ,
288+ {
289+ label : "recursively inlines nested imports" ,
290+ files : {
291+ [ claudePath ] : "@./a.md" ,
292+ [ aPath ] : "A\n@./b.md" ,
293+ [ bPath ] : "B" ,
294+ } ,
295+ expected : "A\nB" ,
296+ } ,
297+ {
298+ label : "leaves the reference literal on a cycle" ,
299+ files : {
300+ [ claudePath ] : "@./a.md" ,
301+ [ aPath ] : "A\n@./a.md" ,
302+ } ,
303+ expected : "A\n@./a.md" ,
304+ } ,
305+ {
306+ label : "leaves a missing import as its literal reference" ,
307+ files : { [ claudePath ] : "top\n@./missing.md" } ,
308+ expected : "top\n@./missing.md" ,
309+ } ,
310+ {
311+ label : "does not expand imports inside inline code spans" ,
312+ files : {
313+ [ claudePath ] : "mention `@./engineering.md` literally" ,
314+ [ engineeringPath ] : "engineering rules" ,
315+ } ,
316+ expected : "mention `@./engineering.md` literally" ,
317+ } ,
318+ {
319+ label : "does not expand imports inside fenced code blocks" ,
320+ files : {
321+ [ claudePath ] : "```\n@./engineering.md\n```" ,
322+ [ engineeringPath ] : "engineering rules" ,
323+ } ,
324+ expected : "```\n@./engineering.md\n```" ,
325+ } ,
326+ {
327+ label : "a shorter fence line does not close a longer fence" ,
328+ files : {
329+ [ claudePath ] : "````\n```\n@./engineering.md\n```\n````" ,
330+ [ engineeringPath ] : "engineering rules" ,
331+ } ,
332+ expected : "````\n```\n@./engineering.md\n```\n````" ,
333+ } ,
334+ {
335+ label : "a longer fence line closes a shorter fence" ,
336+ files : {
337+ [ claudePath ] : "```\n@./engineering.md\n````\n@./engineering.md" ,
338+ [ engineeringPath ] : "engineering rules" ,
339+ } ,
340+ expected : "```\n@./engineering.md\n````\nengineering rules" ,
341+ } ,
342+ {
343+ label : "a backtick fence line does not close a tilde fence" ,
344+ files : {
345+ [ claudePath ] : "~~~\n```\n@./engineering.md\n~~~" ,
346+ [ engineeringPath ] : "engineering rules" ,
347+ } ,
348+ expected : "~~~\n```\n@./engineering.md\n~~~" ,
349+ } ,
350+ {
351+ label : "a tilde fence line does not close a backtick fence" ,
352+ files : {
353+ [ claudePath ] : "```\n~~~\n@./engineering.md\n```" ,
354+ [ engineeringPath ] : "engineering rules" ,
355+ } ,
356+ expected : "```\n~~~\n@./engineering.md\n```" ,
357+ } ,
358+ {
359+ label : "expands after a normally closed fence" ,
360+ files : {
361+ [ claudePath ] : "```\n@./engineering.md\n```\n@./engineering.md" ,
362+ [ engineeringPath ] : "engineering rules" ,
363+ } ,
364+ expected : "```\n@./engineering.md\n```\nengineering rules" ,
365+ } ,
366+ {
367+ label : "a fence line with an info string is not a closer" ,
368+ files : {
369+ [ claudePath ] : "```\n@./engineering.md\n``` js\n@./engineering.md\n```" ,
370+ [ engineeringPath ] : "engineering rules" ,
371+ } ,
372+ expected : "```\n@./engineering.md\n``` js\n@./engineering.md\n```" ,
373+ } ,
374+ {
375+ label :
376+ "a backtick run with backticks in its info string is a span, not a fence" ,
377+ files : {
378+ [ claudePath ] : "```@x```\n@./engineering.md" ,
379+ [ engineeringPath ] : "engineering rules" ,
380+ } ,
381+ expected : "```@x```\nengineering rules" ,
382+ } ,
383+ {
384+ label : "keeps an indented code line after a blank line literal" ,
385+ files : {
386+ [ claudePath ] : "intro\n\n @./engineering.md" ,
387+ [ engineeringPath ] : "engineering rules" ,
388+ } ,
389+ expected : "intro\n\n @./engineering.md" ,
390+ } ,
391+ {
392+ label : "keeps a tab-indented code line after a blank line literal" ,
393+ files : {
394+ [ claudePath ] : "intro\n\n\t@./engineering.md" ,
395+ [ engineeringPath ] : "engineering rules" ,
396+ } ,
397+ expected : "intro\n\n\t@./engineering.md" ,
398+ } ,
399+ {
400+ label : "expands an indented list continuation without a preceding blank" ,
401+ files : {
402+ [ claudePath ] : "- see:\n @./engineering.md" ,
403+ [ engineeringPath ] : "engineering rules" ,
404+ } ,
405+ expected : "- see:\n engineering rules" ,
406+ } ,
407+ {
408+ label :
409+ "an indented code block survives internal blanks and ends on dedent" ,
410+ files : {
411+ [ claudePath ] :
412+ "intro\n\n @./engineering.md\n\nafter @./engineering.md" ,
413+ [ engineeringPath ] : "engineering rules" ,
414+ } ,
415+ expected : "intro\n\n @./engineering.md\n\nafter engineering rules" ,
416+ } ,
417+ {
418+ label : "a three-space indent is not an indented code block" ,
419+ files : {
420+ [ claudePath ] : "intro\n\n @./engineering.md" ,
421+ [ engineeringPath ] : "engineering rules" ,
422+ } ,
423+ expected : "intro\n\n engineering rules" ,
424+ } ,
425+ {
426+ label : "a four-space-indented fence line is indented code, not a fence" ,
427+ files : {
428+ [ claudePath ] : "intro\n\n ````\n@./engineering.md" ,
429+ [ engineeringPath ] : "engineering rules" ,
430+ } ,
431+ expected : "intro\n\n ````\nengineering rules" ,
432+ } ,
433+ {
434+ label : "does not expand imports inside double-backtick code spans" ,
435+ files : {
436+ [ claudePath ] : "see `` @./engineering.md `` then @./engineering.md" ,
437+ [ engineeringPath ] : "engineering rules" ,
438+ } ,
439+ expected : "see `` @./engineering.md `` then engineering rules" ,
440+ } ,
441+ {
442+ label : "a double-backtick span may contain a single backtick" ,
443+ files : {
444+ [ claudePath ] : "`` a`b `` @./engineering.md" ,
445+ [ engineeringPath ] : "engineering rules" ,
446+ } ,
447+ expected : "`` a`b `` engineering rules" ,
448+ } ,
449+ {
450+ label : "expands after an unmatched backtick run" ,
451+ files : {
452+ [ claudePath ] : "a lone ` backtick then @./engineering.md" ,
453+ [ engineeringPath ] : "engineering rules" ,
454+ } ,
455+ expected : "a lone ` backtick then engineering rules" ,
456+ } ,
457+ {
458+ label : "expands outside a span at the start of a line" ,
459+ files : {
460+ [ claudePath ] : "`@./engineering.md` and @./engineering.md" ,
461+ [ engineeringPath ] : "engineering rules" ,
462+ } ,
463+ expected : "`@./engineering.md` and engineering rules" ,
464+ } ,
465+ {
466+ label : "expands between two code spans" ,
467+ files : {
468+ [ claudePath ] : "`a` @./engineering.md `b`" ,
469+ [ engineeringPath ] : "engineering rules" ,
470+ } ,
471+ expected : "`a` engineering rules `b`" ,
472+ } ,
473+ ] ) ( "$label" , async ( { files, expected } ) => {
474+ const { service } = createService ( ) ;
475+ givenFiles ( files ) ;
476+
477+ const result = await service . getUserAgentInstructions ( ) ;
478+ expect ( result ?. content ) . toBe ( expected ) ;
479+ expect ( result ?. truncated ) . toBe ( false ) ;
480+ } ) ;
481+
482+ it ( "stops following imports past the max depth" , async ( ) => {
483+ const { service } = createService ( ) ;
484+ const chain = path . join ( claudeDir , "d5.md" ) ;
485+ givenFiles ( {
486+ [ claudePath ] : "@./d1.md" ,
487+ [ path . join ( claudeDir , "d1.md" ) ] : "1\n@./d2.md" ,
488+ [ path . join ( claudeDir , "d2.md" ) ] : "2\n@./d3.md" ,
489+ [ path . join ( claudeDir , "d3.md" ) ] : "3\n@./d4.md" ,
490+ [ path . join ( claudeDir , "d4.md" ) ] : "4\n@./d5.md" ,
491+ [ chain ] : "5" ,
492+ } ) ;
493+
494+ const result = await service . getUserAgentInstructions ( ) ;
495+ expect ( result ?. content ) . toBe ( "1\n2\n3\n4\n@./d5.md" ) ;
496+ } ) ;
497+
498+ it ( "resolves relative imports against a symlinked file's real directory" , async ( ) => {
499+ const { service } = createService ( ) ;
500+ const realDir = path . join ( path . sep , "dotfiles" , "claude" ) ;
501+ const realClaudePath = path . join ( realDir , "CLAUDE.md" ) ;
502+ const realEngineeringPath = path . join ( realDir , "engineering.md" ) ;
503+
504+ mockRealpath . mockImplementation ( async ( p : string ) =>
505+ p === claudePath ? realClaudePath : p ,
506+ ) ;
507+ givenFiles ( {
508+ [ claudePath ] : "root\n@./engineering.md" ,
509+ [ realEngineeringPath ] : "engineering rules from dotfiles" ,
510+ } ) ;
511+
512+ const result = await service . getUserAgentInstructions ( ) ;
513+ expect ( result ?. content ) . toBe ( "root\nengineering rules from dotfiles" ) ;
514+ } ) ;
515+
516+ it ( "applies the length cap after expansion" , async ( ) => {
517+ const { service } = createService ( ) ;
518+ givenFiles ( {
519+ [ claudePath ] : "@./big.md" ,
520+ [ path . join ( claudeDir , "big.md" ) ] : "x" . repeat ( 25_000 ) ,
521+ } ) ;
522+
523+ const result = await service . getUserAgentInstructions ( ) ;
524+ expect ( result ?. content ) . toHaveLength ( 20_000 ) ;
525+ expect ( result ?. truncated ) . toBe ( true ) ;
526+ } ) ;
527+ } ) ;
528+
256529describe ( "OsService.getClaudePermissions" , ( ) => {
257530 it ( "returns the allow and deny arrays from the settings file" , async ( ) => {
258531 const { service } = createService ( ) ;
0 commit comments