@@ -1322,3 +1322,338 @@ describe("Rules directory reading", () => {
13221322 expect ( result ) . toBe ( "\n# Rules from .roorules:\nfallback content\n" )
13231323 } )
13241324} )
1325+
1326+ describe ( "loadCustomInstructionFiles" , ( ) => {
1327+ beforeEach ( ( ) => {
1328+ vi . clearAllMocks ( )
1329+ } )
1330+
1331+ it ( "should load custom instruction files from specified paths" , async ( ) => {
1332+ // Mock file existence and content
1333+ statMock . mockImplementation ( ( path ) => {
1334+ const normalizedPath = path . toString ( ) . replace ( / \\ / g, "/" )
1335+ if (
1336+ normalizedPath . endsWith ( ".github/copilot-instructions.md" ) ||
1337+ normalizedPath . endsWith ( "docs/ai-instructions.md" )
1338+ ) {
1339+ return Promise . resolve ( {
1340+ isFile : vi . fn ( ) . mockReturnValue ( true ) ,
1341+ size : 1000 , // Less than 1MB
1342+ } as any )
1343+ }
1344+ return Promise . reject ( { code : "ENOENT" } )
1345+ } )
1346+
1347+ readFileMock . mockImplementation ( ( filePath : PathLike ) => {
1348+ const pathStr = filePath . toString ( )
1349+ const normalizedPath = pathStr . replace ( / \\ / g, "/" )
1350+ if ( normalizedPath . endsWith ( ".github/copilot-instructions.md" ) ) {
1351+ return Promise . resolve ( "GitHub Copilot instructions content" )
1352+ }
1353+ if ( normalizedPath . endsWith ( "docs/ai-instructions.md" ) ) {
1354+ return Promise . resolve ( "AI instructions content" )
1355+ }
1356+ return Promise . reject ( { code : "ENOENT" } )
1357+ } )
1358+
1359+ const result = await addCustomInstructions (
1360+ "mode instructions" ,
1361+ "global instructions" ,
1362+ "/fake/path" ,
1363+ "test-mode" ,
1364+ { customInstructionPaths : [ ".github/copilot-instructions.md" , "docs/ai-instructions.md" ] } ,
1365+ )
1366+
1367+ expect ( result ) . toContain ( "# Custom instructions from .github/copilot-instructions.md:" )
1368+ expect ( result ) . toContain ( "GitHub Copilot instructions content" )
1369+ expect ( result ) . toContain ( "# Custom instructions from docs/ai-instructions.md:" )
1370+ expect ( result ) . toContain ( "AI instructions content" )
1371+ } )
1372+
1373+ it ( "should skip files outside workspace" , async ( ) => {
1374+ // Mock no .roo/rules-test-mode directory
1375+ statMock . mockRejectedValueOnce ( { code : "ENOENT" } )
1376+
1377+ // Mock file existence for custom instruction paths
1378+ statMock . mockImplementation ( ( path ) => {
1379+ return Promise . resolve ( {
1380+ isFile : vi . fn ( ) . mockReturnValue ( true ) ,
1381+ size : 1000 ,
1382+ } as any )
1383+ } )
1384+
1385+ // Mock file reads - only return content for files that shouldn't be loaded
1386+ readFileMock . mockImplementation ( ( filePath : PathLike ) => {
1387+ const pathStr = filePath . toString ( )
1388+ if ( pathStr . includes ( "/etc/passwd" ) || pathStr . includes ( "/root/.ssh/config" ) ) {
1389+ return Promise . resolve ( "Should not be loaded" )
1390+ }
1391+ // Return empty/rejected for other files (like .roorules-test-mode)
1392+ return Promise . reject ( { code : "ENOENT" } )
1393+ } )
1394+
1395+ // Mock console.warn to verify warning is logged
1396+ const consoleWarnSpy = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } )
1397+
1398+ const result = await addCustomInstructions (
1399+ "mode instructions" ,
1400+ "global instructions" ,
1401+ "/fake/path" ,
1402+ "test-mode" ,
1403+ { customInstructionPaths : [ "/etc/passwd" , "/root/.ssh/config" ] } ,
1404+ )
1405+
1406+ expect ( result ) . not . toContain ( "Should not be loaded" )
1407+ expect ( consoleWarnSpy ) . toHaveBeenCalledWith (
1408+ expect . stringContaining ( "Skipping custom instruction file outside workspace" ) ,
1409+ )
1410+
1411+ consoleWarnSpy . mockRestore ( )
1412+ } )
1413+
1414+ it ( "should allow files in parent directories for monorepo scenarios" , async ( ) => {
1415+ // Mock no .roo/rules-test-mode directory
1416+ statMock . mockRejectedValueOnce ( { code : "ENOENT" } )
1417+
1418+ // Mock file existence - need to handle the joined path
1419+ statMock . mockImplementation ( ( filePath ) => {
1420+ const pathStr = filePath . toString ( )
1421+ // Handle both Unix and Windows path separators
1422+ const normalizedPath = pathStr . replace ( / \\ / g, "/" )
1423+ // The path.join will create /fake/path/monorepo-instructions.md
1424+ if ( normalizedPath . endsWith ( "monorepo-instructions.md" ) ) {
1425+ return Promise . resolve ( {
1426+ isFile : vi . fn ( ) . mockReturnValue ( true ) ,
1427+ size : 1000 ,
1428+ } as any )
1429+ }
1430+ return Promise . reject ( { code : "ENOENT" } )
1431+ } )
1432+
1433+ readFileMock . mockImplementation ( ( filePath : PathLike ) => {
1434+ const pathStr = filePath . toString ( )
1435+ const normalizedPath = pathStr . replace ( / \\ / g, "/" )
1436+ if ( normalizedPath . endsWith ( "monorepo-instructions.md" ) ) {
1437+ return Promise . resolve ( "Monorepo instructions content" )
1438+ }
1439+ return Promise . reject ( { code : "ENOENT" } )
1440+ } )
1441+
1442+ const result = await addCustomInstructions (
1443+ "mode instructions" ,
1444+ "global instructions" ,
1445+ "/fake/path/subproject" ,
1446+ "test-mode" ,
1447+ { customInstructionPaths : [ "../monorepo-instructions.md" ] } ,
1448+ )
1449+
1450+ expect ( result ) . toContain ( "# Custom instructions from ../monorepo-instructions.md:" )
1451+ expect ( result ) . toContain ( "Monorepo instructions content" )
1452+ } )
1453+
1454+ it ( "should skip non-existent files with warning" , async ( ) => {
1455+ // Mock file doesn't exist
1456+ statMock . mockRejectedValue ( { code : "ENOENT" } )
1457+
1458+ const consoleWarnSpy = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } )
1459+
1460+ const result = await addCustomInstructions (
1461+ "mode instructions" ,
1462+ "global instructions" ,
1463+ "/fake/path" ,
1464+ "test-mode" ,
1465+ { customInstructionPaths : [ "non-existent.md" ] } ,
1466+ )
1467+
1468+ expect ( result ) . not . toContain ( "non-existent.md" )
1469+ expect ( consoleWarnSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( "Custom instruction file not found" ) )
1470+
1471+ consoleWarnSpy . mockRestore ( )
1472+ } )
1473+
1474+ it ( "should skip non-markdown/text files" , async ( ) => {
1475+ // Mock file existence
1476+ statMock . mockImplementation ( ( path ) => {
1477+ return Promise . resolve ( {
1478+ isFile : vi . fn ( ) . mockReturnValue ( true ) ,
1479+ size : 1000 ,
1480+ } as any )
1481+ } )
1482+
1483+ const consoleWarnSpy = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } )
1484+
1485+ const result = await addCustomInstructions (
1486+ "mode instructions" ,
1487+ "global instructions" ,
1488+ "/fake/path" ,
1489+ "test-mode" ,
1490+ { customInstructionPaths : [ "binary.exe" , "image.png" , "data.json" ] } ,
1491+ )
1492+
1493+ expect ( result ) . not . toContain ( "binary.exe" )
1494+ expect ( result ) . not . toContain ( "image.png" )
1495+ expect ( result ) . not . toContain ( "data.json" )
1496+ expect ( consoleWarnSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( "Skipping non-markdown/text file" ) )
1497+
1498+ consoleWarnSpy . mockRestore ( )
1499+ } )
1500+
1501+ it ( "should skip files larger than 1MB" , async ( ) => {
1502+ // Mock file existence with large size
1503+ statMock . mockImplementation ( ( path ) => {
1504+ return Promise . resolve ( {
1505+ isFile : vi . fn ( ) . mockReturnValue ( true ) ,
1506+ size : 2 * 1024 * 1024 , // 2MB
1507+ } as any )
1508+ } )
1509+
1510+ const consoleWarnSpy = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } )
1511+
1512+ const result = await addCustomInstructions (
1513+ "mode instructions" ,
1514+ "global instructions" ,
1515+ "/fake/path" ,
1516+ "test-mode" ,
1517+ { customInstructionPaths : [ "large-file.md" ] } ,
1518+ )
1519+
1520+ expect ( result ) . not . toContain ( "large-file.md" )
1521+ expect ( consoleWarnSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( "Skipping large file (>1MB)" ) )
1522+
1523+ consoleWarnSpy . mockRestore ( )
1524+ } )
1525+
1526+ it ( "should handle invalid path patterns" , async ( ) => {
1527+ const consoleWarnSpy = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } )
1528+
1529+ const result = await addCustomInstructions (
1530+ "mode instructions" ,
1531+ "global instructions" ,
1532+ "/fake/path" ,
1533+ "test-mode" ,
1534+ { customInstructionPaths : [ null as any , "" , "path/../../../etc/passwd" ] } ,
1535+ )
1536+
1537+ expect ( consoleWarnSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( "Invalid custom instruction path" ) )
1538+ expect ( consoleWarnSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( "Potentially dangerous path pattern" ) )
1539+
1540+ consoleWarnSpy . mockRestore ( )
1541+ } )
1542+
1543+ it ( "should handle errors gracefully" , async ( ) => {
1544+ // Mock no .roo/rules-test-mode directory
1545+ statMock . mockRejectedValueOnce ( { code : "ENOENT" } )
1546+
1547+ // Mock file existence
1548+ statMock . mockImplementation ( ( path ) => {
1549+ const normalizedPath = path . toString ( ) . replace ( / \\ / g, "/" )
1550+ if ( normalizedPath . endsWith ( "restricted.md" ) ) {
1551+ return Promise . resolve ( {
1552+ isFile : vi . fn ( ) . mockReturnValue ( true ) ,
1553+ size : 1000 ,
1554+ } as any )
1555+ }
1556+ return Promise . reject ( { code : "ENOENT" } )
1557+ } )
1558+
1559+ // Mock read error for specific file
1560+ readFileMock . mockImplementation ( ( filePath : PathLike ) => {
1561+ const pathStr = filePath . toString ( )
1562+ if ( pathStr . includes ( "restricted.md" ) ) {
1563+ return Promise . reject ( new Error ( "Permission denied" ) )
1564+ }
1565+ return Promise . reject ( { code : "ENOENT" } )
1566+ } )
1567+
1568+ const consoleWarnSpy = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } )
1569+
1570+ const result = await addCustomInstructions (
1571+ "mode instructions" ,
1572+ "global instructions" ,
1573+ "/fake/path" ,
1574+ "test-mode" ,
1575+ { customInstructionPaths : [ "restricted.md" ] } ,
1576+ )
1577+
1578+ expect ( result ) . not . toContain ( "restricted.md" )
1579+ expect ( consoleWarnSpy ) . toHaveBeenCalledWith (
1580+ expect . stringContaining ( "Failed to load custom instruction file" ) ,
1581+ expect . any ( Error ) ,
1582+ )
1583+
1584+ consoleWarnSpy . mockRestore ( )
1585+ } )
1586+
1587+ it ( "should handle absolute paths correctly" , async ( ) => {
1588+ // Mock file existence
1589+ statMock . mockImplementation ( ( path ) => {
1590+ const normalizedPath = path . toString ( ) . replace ( / \\ / g, "/" )
1591+ if ( normalizedPath === "/fake/path/absolute-instructions.md" ) {
1592+ return Promise . resolve ( {
1593+ isFile : vi . fn ( ) . mockReturnValue ( true ) ,
1594+ size : 1000 ,
1595+ } as any )
1596+ }
1597+ return Promise . reject ( { code : "ENOENT" } )
1598+ } )
1599+
1600+ readFileMock . mockImplementation ( ( filePath : PathLike ) => {
1601+ const pathStr = filePath . toString ( )
1602+ const normalizedPath = pathStr . replace ( / \\ / g, "/" )
1603+ if ( normalizedPath === "/fake/path/absolute-instructions.md" ) {
1604+ return Promise . resolve ( "Absolute path instructions" )
1605+ }
1606+ return Promise . reject ( { code : "ENOENT" } )
1607+ } )
1608+
1609+ const result = await addCustomInstructions (
1610+ "mode instructions" ,
1611+ "global instructions" ,
1612+ "/fake/path" ,
1613+ "test-mode" ,
1614+ { customInstructionPaths : [ "/fake/path/absolute-instructions.md" ] } ,
1615+ )
1616+
1617+ expect ( result ) . toContain ( "# Custom instructions from /fake/path/absolute-instructions.md:" )
1618+ expect ( result ) . toContain ( "Absolute path instructions" )
1619+ } )
1620+
1621+ it ( "should return empty string when no paths provided" , async ( ) => {
1622+ const result = await addCustomInstructions (
1623+ "mode instructions" ,
1624+ "global instructions" ,
1625+ "/fake/path" ,
1626+ "test-mode" ,
1627+ { customInstructionPaths : [ ] } ,
1628+ )
1629+
1630+ expect ( result ) . toContain ( "Mode-specific Instructions:" )
1631+ expect ( result ) . not . toContain ( "# Custom instructions from" )
1632+ } )
1633+
1634+ it ( "should validate that path is a file not a directory" , async ( ) => {
1635+ // Mock path exists but is a directory
1636+ statMock . mockImplementation ( ( path ) => {
1637+ return Promise . resolve ( {
1638+ isFile : vi . fn ( ) . mockReturnValue ( false ) ,
1639+ isDirectory : vi . fn ( ) . mockReturnValue ( true ) ,
1640+ size : 0 ,
1641+ } as any )
1642+ } )
1643+
1644+ const consoleWarnSpy = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } )
1645+
1646+ const result = await addCustomInstructions (
1647+ "mode instructions" ,
1648+ "global instructions" ,
1649+ "/fake/path" ,
1650+ "test-mode" ,
1651+ { customInstructionPaths : [ "some-directory" ] } ,
1652+ )
1653+
1654+ expect ( result ) . not . toContain ( "some-directory" )
1655+ expect ( consoleWarnSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( "Path is not a file" ) )
1656+
1657+ consoleWarnSpy . mockRestore ( )
1658+ } )
1659+ } )
0 commit comments