From e89210440076eec58a629d2b557d67cb646ceab5 Mon Sep 17 00:00:00 2001 From: Vincent Lussenburg Date: Wed, 3 Dec 2025 08:15:21 -0800 Subject: [PATCH 1/5] Add readMarkdownWithLinks filter --- .../filters/readMarkdownWithLinks/README.md | 65 +++++ .../filters/readMarkdownWithLinks/index.js | 193 ++++++++++++++ .../read_markdown_with_links.cm | 93 +++++++ plugins/filters/readMarkdownWithLinks/test.js | 245 ++++++++++++++++++ 4 files changed, 596 insertions(+) create mode 100644 plugins/filters/readMarkdownWithLinks/README.md create mode 100644 plugins/filters/readMarkdownWithLinks/index.js create mode 100644 plugins/filters/readMarkdownWithLinks/read_markdown_with_links.cm create mode 100644 plugins/filters/readMarkdownWithLinks/test.js diff --git a/plugins/filters/readMarkdownWithLinks/README.md b/plugins/filters/readMarkdownWithLinks/README.md new file mode 100644 index 00000000..dacc88c3 --- /dev/null +++ b/plugins/filters/readMarkdownWithLinks/README.md @@ -0,0 +1,65 @@ +??? note "Plugin Code: readMarkdownWithLinks" + ```javascript + --8<-- "plugins/filters/readMarkdownWithLinks/index.js" + ``` +
+ + +
+ +The main use case for this plugin is enhancing LinearB AI code reviews with comprehensive documentation context. + +### Basic Usage +```yaml +guidelines: | + {{ "REVIEW_RULES.md" | readMarkdownWithLinks }} + + Additional Context: + {{ "README.md" | readMarkdownWithLinks(maxDepth=2) }} +``` + +## Configuration Options + +- `followLinks` (boolean, default: `true`): Whether to follow internal markdown links +- `maxDepth` (number, default: `3`): Maximum depth to follow links to prevent excessive recursion + +## API + +### `readMarkdownWithLinks(filePath, options)` + +Returns the combined content of the main file and all linked files as a formatted string. + +### `readMarkdown(filePath, options)` + +Returns a structured object containing: +- `path`: Absolute path to the file +- `content`: File content +- `error`: Any error encountered +- `linkedFiles`: Array of linked file objects with the same structure + +## Example Output +``` +=== main.md === +# Main Document +Content of main document... + + === related.md === + # Related Document + Content of related document... + + === subdoc.md === + # Sub Document + Content of sub document... +``` + + +??? example "gitStream CM Example: readMarkdownWithLinks" + ```yaml+jinja + --8<-- "plugins/filters/readMarkdownWithLinks/read_markdown_with_links.cm" + ``` +
+ + +
+ +[Download Source Code](https://github.com/linear-b/gitstream/tree/main/plugins/filters/readMarkdownWithLinks) \ No newline at end of file diff --git a/plugins/filters/readMarkdownWithLinks/index.js b/plugins/filters/readMarkdownWithLinks/index.js new file mode 100644 index 00000000..12de1fde --- /dev/null +++ b/plugins/filters/readMarkdownWithLinks/index.js @@ -0,0 +1,193 @@ +const fs = require('fs'); +const path = require('path'); + +/** + * Read file function - in GitStream environment this would be provided + * For standalone testing, we use fs.readFileSync + * @param {string} filePath - Path to file to read + * @returns {string|null} File content or null if error + */ +function readFile(filePath) { + try { + // In GitStream, replace this with: return gitstream.readFile(filePath); + return fs.readFileSync(filePath, 'utf8'); + } catch (error) { + console.log(`Error reading file ${filePath}: ${error.message}`); + return null; + } +} + +/** + * Extract internal markdown links from content + * Matches patterns like [text](./file.md) or [text](../file.md) or [text](file.md) + * @param {string} content - The markdown content to scan for links + * @param {string} basePath - Base directory path for resolving relative links + * @returns {Array} Array of link objects with text, path, and resolvedPath + */ +function extractInternalLinks(content, basePath) { + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + const internalLinks = []; + let match; + + while ((match = linkRegex.exec(content)) !== null) { + const linkText = match[1]; + const linkPath = match[2]; + + // Check if it's an internal link (not http/https and ends with .md) + if (!linkPath.startsWith('http') && linkPath.endsWith('.md')) { + const resolvedPath = path.resolve(basePath, linkPath); + internalLinks.push({ + text: linkText, + path: linkPath, + resolvedPath: resolvedPath + }); + } + } + + return internalLinks; +} + +/** + * Read markdown file and follow internal links + * @param {string} filePath - Path to the markdown file + * @param {Object} options - Configuration options + * @param {boolean} options.followLinks - Whether to follow internal links (default: true) + * @param {number} options.maxDepth - Maximum depth to follow links (default: 3) + * @param {Set} options.visited - Internal set to track visited files (prevent cycles) + * @param {number} options.currentDepth - Current depth (internal) + * @returns {Object} Object containing content and linked files + */ +function readMarkdown(filePath, options = {}) { + const { + followLinks = true, + maxDepth = 3, + visited = new Set(), + currentDepth = 0 + } = options; + + // Resolve the absolute path + const absolutePath = path.resolve(filePath); + + // Check if we've already visited this file (prevent cycles) + if (visited.has(absolutePath)) { + return { + path: absolutePath, + content: null, + error: 'Circular reference detected', + linkedFiles: [] + }; + } + + // Check depth limit + if (currentDepth >= maxDepth) { + return { + path: absolutePath, + content: readFile(absolutePath), + error: null, + linkedFiles: [], + depthLimitReached: true + }; + } + + // Mark this file as visited + visited.add(absolutePath); + + // Read the main file content + const content = readFile(absolutePath); + if (!content) { + return { + path: absolutePath, + content: null, + error: 'File not found or could not be read', + linkedFiles: [] + }; + } + + const result = { + path: absolutePath, + content: content, + error: null, + linkedFiles: [] + }; + + // If we should follow links, extract and process them + if (followLinks) { + const basePath = path.dirname(absolutePath); + const internalLinks = extractInternalLinks(content, basePath); + + for (const link of internalLinks) { + const linkedFileResult = readMarkdown(link.resolvedPath, { + followLinks, + maxDepth, + visited: new Set(visited), // Create a new set for each branch + currentDepth: currentDepth + 1 + }); + + result.linkedFiles.push({ + linkText: link.text, + originalPath: link.path, + ...linkedFileResult + }); + } + } + + return result; +} + +/** + * @module readMarkdownWithLinks + * @description Reads a markdown file and follows internal links to create a comprehensive document view. + * Uses GitStream's readFile under the hood but extends it to recursively follow markdown link references. + * Prevents circular references and supports configurable depth limits. + * @param {string} filePath - Path to the markdown file to read + * @param {Object} [options={}] - Configuration options for link following + * @param {boolean} [options.followLinks=true] - Whether to follow internal links + * @param {number} [options.maxDepth=3] - Maximum depth to follow links + * @param {boolean} [options.structured=false] - Return structured data instead of combined text + * @returns {string} Combined content of the file and all linked files with headers + * @example {{ "docs/README.md" | readMarkdownWithLinks }} + * @example {{ "docs/README.md" | readMarkdownWithLinks(maxDepth=2) }} + * @license MIT + */ +function readMarkdownWithLinks(filePath, options = {}) { + const { + followLinks = true, + maxDepth = 3, + structured = false + } = options; + + const result = readMarkdown(filePath, { + followLinks, + maxDepth, + visited: new Set(), + currentDepth: 0 + }); + + // Return structured data if requested + if (structured) { + return result; + } + + // Otherwise return combined content + function combineContent(fileResult, depth = 0) { + const indent = ' '.repeat(depth); + let combined = ''; + + if (fileResult.content) { + combined += `${indent}=== ${path.basename(fileResult.path)} ===\n`; + combined += fileResult.content + '\n\n'; + } + + if (fileResult.linkedFiles) { + for (const linkedFile of fileResult.linkedFiles) { + combined += combineContent(linkedFile, depth + 1); + } + } + + return combined; + } + + return combineContent(result); +} + +module.exports = readMarkdownWithLinks; \ No newline at end of file diff --git a/plugins/filters/readMarkdownWithLinks/read_markdown_with_links.cm b/plugins/filters/readMarkdownWithLinks/read_markdown_with_links.cm new file mode 100644 index 00000000..46c203c1 --- /dev/null +++ b/plugins/filters/readMarkdownWithLinks/read_markdown_with_links.cm @@ -0,0 +1,93 @@ +# -*- mode: yaml -*- +# Example GitStream configuration using readMarkdownWithLinks for LinearB AI code reviews +# This shows how to enhance AI code reviews with comprehensive documentation context + +manifest: + version: 1.0 + +automations: + # Enhanced AI code review with comprehensive documentation context + ai_review_with_full_docs: + if: + - {{ not pr.draft }} + - {{ pr.files | match(regex=r".*\.(js|ts|py|go|java|cpp|cs)") | some }} + run: + - action: code-review@v1 + args: + guidelines: | + Code Review Guidelines: + {{ "REVIEW_RULES.md" | readMarkdownWithLinks | dump }} + + Project Documentation Context: + {{ "README.md" | readMarkdownWithLinks(maxDepth=2) | dump }} + + Architecture and Design: + {{ "docs/ARCHITECTURE.md" | readMarkdownWithLinks(maxDepth=1) | dump }} + + # Context-aware reviews based on changed file areas + contextual_ai_review: + if: + - {{ not pr.draft }} + run: + - action: code-review@v1 + args: + guidelines: | + Base Review Guidelines: + {{ "REVIEW_RULES.md" | readMarkdownWithLinks | dump }} + + {% if pr.files | match(regex=r"src/api/.*") | some %} + API-Specific Guidelines and Documentation: + {{ "docs/api/README.md" | readMarkdownWithLinks | dump }} + {% endif %} + + {% if pr.files | match(regex=r".*test.*") | some %} + Testing Standards and Guidelines: + {{ "docs/testing/README.md" | readMarkdownWithLinks | dump }} + {% endif %} + + {% if pr.files | match(regex=r".*security.*") | some %} + Security Guidelines: + {{ "docs/security/GUIDELINES.md" | readMarkdownWithLinks | dump }} + {% endif %} + + # Large PR reviews with extensive context + comprehensive_review_large_prs: + if: + - {{ not pr.draft }} + - {{ pr.files | length > 10 }} # Large changes + run: + - action: code-review@v1 + args: + guidelines: | + Comprehensive Review Guidelines for Large Changes: + {{ "REVIEW_RULES.md" | readMarkdownWithLinks | dump }} + + Full Project Context: + {{ "README.md" | readMarkdownWithLinks(maxDepth=1) | dump }} + + Contributing Guidelines: + {{ "CONTRIBUTING.md" | readMarkdownWithLinks | dump }} + + Architecture Documentation: + {{ "docs/ARCHITECTURE.md" | readMarkdownWithLinks(maxDepth=2) | dump }} + + # First-time contributor guidance + enhanced_review_new_contributors: + if: + - {{ not pr.draft }} + - {{ pr.author_is_new_contributor }} + run: + - action: code-review@v1 + args: + guidelines: | + Welcome! Here are our code review guidelines with full context: + {{ "REVIEW_RULES.md" | readMarkdownWithLinks | dump }} + + New Contributor Guide: + {{ "docs/ONBOARDING.md" | readMarkdownWithLinks | dump }} + + Project Overview: + {{ "README.md" | readMarkdownWithLinks(maxDepth=1) | dump }} + + Contributing Guidelines: + {{ "CONTRIBUTING.md" | readMarkdownWithLinks(maxDepth=1) | dump }} \ No newline at end of file diff --git a/plugins/filters/readMarkdownWithLinks/test.js b/plugins/filters/readMarkdownWithLinks/test.js new file mode 100644 index 00000000..d552e2a4 --- /dev/null +++ b/plugins/filters/readMarkdownWithLinks/test.js @@ -0,0 +1,245 @@ +const readMarkdownWithLinks = require('./index.js'); + +const fs = require('fs'); +const path = require('path'); + +// Copy the internal functions for testing +function extractInternalLinks(content, basePath) { + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; + const internalLinks = []; + let match; + + while ((match = linkRegex.exec(content)) !== null) { + const linkText = match[1]; + const linkPath = match[2]; + + if (!linkPath.startsWith('http') && linkPath.endsWith('.md')) { + const resolvedPath = path.resolve(basePath, linkPath); + internalLinks.push({ + text: linkText, + path: linkPath, + resolvedPath: resolvedPath + }); + } + } + + return internalLinks; +} + +function readMarkdown(filePath, options = {}) { + return readMarkdownWithLinks(filePath, { ...options, structured: true }); +} + +// Simple assertion function +function assert(condition, message) { + if (!condition) { + console.error(`โŒ FAIL: ${message}`); + process.exit(1); + } else { + console.log(`โœ… PASS: ${message}`); + } +} + +// Test suite +function runTests() { + console.log('๐Ÿงช Running GitStream Read Markdown Plugin Tests\n'); + + // Setup test files + setupTestFiles(); + + // Test 1: Basic file reading + testBasicFileReading(); + + // Test 2: Link extraction + testLinkExtraction(); + + // Test 3: Link following + testLinkFollowing(); + + // Test 4: Circular reference detection + testCircularReferenceDetection(); + + // Test 5: Depth limit + testDepthLimit(); + + // Test 6: Non-existent file handling + testNonExistentFile(); + + // Test 7: Combined content format + testCombinedContentFormat(); + + console.log('\n๐ŸŽ‰ All tests passed!'); +} + +function setupTestFiles() { + console.log('๐Ÿ“ Setting up test files...'); + + // Main file + const mainContent = `# Main Document + +This is the main document. + +See also: +- [Related document](./related.md) +- [Another document](./another.md) +- [External link](https://example.com) + +## Main Content +This is some main content. +`; + + // Related file + const relatedContent = `# Related Document + +This document is related to the main one. + +Check out [Sub document](./sub/subdoc.md) for more details. + +## Related Content +Some related information here. +`; + + // Another file + const anotherContent = `# Another Document + +This is another document. + +## Another Content +More information in this document. +`; + + // Sub document + const subContent = `# Sub Document + +This is a sub document. + +Back to [main](../main.md) document. + +## Sub Content +Sub document information. +`; + + // Create test directory structure + fs.mkdirSync('./test-files', { recursive: true }); + fs.mkdirSync('./test-files/sub', { recursive: true }); + + fs.writeFileSync('./test-files/main.md', mainContent); + fs.writeFileSync('./test-files/related.md', relatedContent); + fs.writeFileSync('./test-files/another.md', anotherContent); + fs.writeFileSync('./test-files/sub/subdoc.md', subContent); + + console.log('โœ… Test files created\n'); +} + +function testBasicFileReading() { + console.log('๐Ÿ” Test 1: Basic file reading'); + + const result = readMarkdown('./test-files/main.md', { followLinks: false }); + + assert(result.content !== null, 'Should read file content'); + assert(result.content.includes('# Main Document'), 'Should contain main document title'); + assert(result.error === null, 'Should have no errors'); + assert(result.linkedFiles.length === 0, 'Should not follow links when followLinks=false'); + console.log(''); +} + +function testLinkExtraction() { + console.log('๐Ÿ”— Test 2: Link extraction'); + + const content = `# Test + +See [Related](./related.md) and [Another](./another.md). +Also check [External](https://example.com) and [Non-markdown](./file.txt). +`; + + const links = extractInternalLinks(content, './test-files'); + + assert(links.length === 2, 'Should extract exactly 2 internal markdown links'); + assert(links[0].text === 'Related', 'First link text should be "Related"'); + assert(links[0].path === './related.md', 'First link path should be "./related.md"'); + assert(links[1].text === 'Another', 'Second link text should be "Another"'); + assert(links[1].path === './another.md', 'Second link path should be "./another.md"'); + console.log(''); +} + +function testLinkFollowing() { + console.log('๐Ÿšถ Test 3: Link following'); + + const result = readMarkdown('./test-files/main.md', { followLinks: true, maxDepth: 2 }); + + assert(result.linkedFiles.length === 2, 'Should follow 2 internal links from main.md'); + + const relatedFile = result.linkedFiles.find(f => f.linkText === 'Related document'); + const anotherFile = result.linkedFiles.find(f => f.linkText === 'Another document'); + + assert(relatedFile !== undefined, 'Should find related document'); + assert(anotherFile !== undefined, 'Should find another document'); + assert(relatedFile.content.includes('# Related Document'), 'Related file should have correct content'); + assert(anotherFile.content.includes('# Another Document'), 'Another file should have correct content'); + + // Check that related.md's links are also followed + assert(relatedFile.linkedFiles.length === 1, 'Related document should have 1 linked file'); + assert(relatedFile.linkedFiles[0].linkText === 'Sub document', 'Should follow sub document link'); + console.log(''); +} + +function testCircularReferenceDetection() { + console.log('๐Ÿ”„ Test 4: Circular reference detection'); + + const result = readMarkdown('./test-files/main.md', { followLinks: true, maxDepth: 5 }); + + // Navigate to the circular reference: main -> related -> sub -> main + const relatedFile = result.linkedFiles.find(f => f.linkText === 'Related document'); + const subFile = relatedFile.linkedFiles[0]; + const circularRef = subFile.linkedFiles[0]; + + assert(circularRef.error === 'Circular reference detected', 'Should detect circular reference'); + assert(circularRef.content === null, 'Circular reference should have null content'); + console.log(''); +} + +function testDepthLimit() { + console.log('๐Ÿ“ Test 5: Depth limit'); + + const result = readMarkdown('./test-files/main.md', { followLinks: true, maxDepth: 1 }); + + assert(result.linkedFiles.length === 2, 'Should follow first level links'); + + const relatedFile = result.linkedFiles.find(f => f.linkText === 'Related document'); + assert(relatedFile.linkedFiles.length === 0, 'Should not follow second level links due to depth limit'); + assert(relatedFile.depthLimitReached === true, 'Should indicate depth limit reached'); + console.log(''); +} + +function testNonExistentFile() { + console.log('โŒ Test 6: Non-existent file handling'); + + const result = readMarkdown('./test-files/nonexistent.md'); + + assert(result.content === null, 'Should have null content for non-existent file'); + assert(result.error === 'File not found or could not be read', 'Should have appropriate error message'); + assert(result.linkedFiles.length === 0, 'Should have no linked files'); + console.log(''); +} + +function testCombinedContentFormat() { + console.log('๐Ÿ“„ Test 7: Combined content format'); + + const combined = readMarkdownWithLinks('./test-files/main.md', { maxDepth: 1 }); + + assert(combined.includes('=== main.md ==='), 'Should include main file header'); + assert(combined.includes('=== related.md ==='), 'Should include related file header'); + assert(combined.includes('=== another.md ==='), 'Should include another file header'); + assert(combined.includes('# Main Document'), 'Should include main content'); + assert(combined.includes('# Related Document'), 'Should include related content'); + assert(combined.includes('# Another Document'), 'Should include another content'); + + // Check indentation for nested files + const lines = combined.split('\n'); + const relatedHeaderLine = lines.find(line => line.includes('=== related.md ===')); + assert(relatedHeaderLine.startsWith(' '), 'Related file should be indented'); + console.log(''); +} + +// Run the tests +runTests(); \ No newline at end of file From 3bd0c69174ba1e0d3621ef7c8db2c80a10676c6f Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot Date: Wed, 3 Dec 2025 09:17:00 -0800 Subject: [PATCH 2/5] added test to main file --- .../filters/readMarkdownWithLinks/index.js | 122 +++++++-- plugins/filters/readMarkdownWithLinks/test.js | 245 ------------------ 2 files changed, 102 insertions(+), 265 deletions(-) delete mode 100644 plugins/filters/readMarkdownWithLinks/test.js diff --git a/plugins/filters/readMarkdownWithLinks/index.js b/plugins/filters/readMarkdownWithLinks/index.js index 12de1fde..9359d599 100644 --- a/plugins/filters/readMarkdownWithLinks/index.js +++ b/plugins/filters/readMarkdownWithLinks/index.js @@ -2,15 +2,21 @@ const fs = require('fs'); const path = require('path'); /** - * Read file function - in GitStream environment this would be provided - * For standalone testing, we use fs.readFileSync + * Safely read file with path traversal protection * @param {string} filePath - Path to file to read - * @returns {string|null} File content or null if error + * @returns {string|null} File content or null if error/invalid path */ function readFile(filePath) { + // Whitelist: only allow relative paths within current directory + const normalizedPath = path.normalize(filePath); + + if (path.isAbsolute(normalizedPath) || normalizedPath.includes('..')) { + console.log(`Invalid path: ${filePath}`); + return null; + } + try { - // In GitStream, replace this with: return gitstream.readFile(filePath); - return fs.readFileSync(filePath, 'utf8'); + return fs.readFileSync(normalizedPath, 'utf8'); } catch (error) { console.log(`Error reading file ${filePath}: ${error.message}`); return null; @@ -35,7 +41,7 @@ function extractInternalLinks(content, basePath) { // Check if it's an internal link (not http/https and ends with .md) if (!linkPath.startsWith('http') && linkPath.endsWith('.md')) { - const resolvedPath = path.resolve(basePath, linkPath); + const resolvedPath = path.join(basePath, linkPath); internalLinks.push({ text: linkText, path: linkPath, @@ -65,13 +71,12 @@ function readMarkdown(filePath, options = {}) { currentDepth = 0 } = options; - // Resolve the absolute path - const absolutePath = path.resolve(filePath); + const normalizedPath = path.normalize(filePath); // Check if we've already visited this file (prevent cycles) - if (visited.has(absolutePath)) { + if (visited.has(normalizedPath)) { return { - path: absolutePath, + path: normalizedPath, content: null, error: 'Circular reference detected', linkedFiles: [] @@ -81,8 +86,8 @@ function readMarkdown(filePath, options = {}) { // Check depth limit if (currentDepth >= maxDepth) { return { - path: absolutePath, - content: readFile(absolutePath), + path: normalizedPath, + content: readFile(normalizedPath), error: null, linkedFiles: [], depthLimitReached: true @@ -90,13 +95,13 @@ function readMarkdown(filePath, options = {}) { } // Mark this file as visited - visited.add(absolutePath); + visited.add(normalizedPath); // Read the main file content - const content = readFile(absolutePath); - if (!content) { + const content = readFile(normalizedPath); + if (content === null) { return { - path: absolutePath, + path: normalizedPath, content: null, error: 'File not found or could not be read', linkedFiles: [] @@ -104,7 +109,7 @@ function readMarkdown(filePath, options = {}) { } const result = { - path: absolutePath, + path: normalizedPath, content: content, error: null, linkedFiles: [] @@ -112,7 +117,7 @@ function readMarkdown(filePath, options = {}) { // If we should follow links, extract and process them if (followLinks) { - const basePath = path.dirname(absolutePath); + const basePath = path.dirname(normalizedPath); const internalLinks = extractInternalLinks(content, basePath); for (const link of internalLinks) { @@ -137,7 +142,6 @@ function readMarkdown(filePath, options = {}) { /** * @module readMarkdownWithLinks * @description Reads a markdown file and follows internal links to create a comprehensive document view. - * Uses GitStream's readFile under the hood but extends it to recursively follow markdown link references. * Prevents circular references and supports configurable depth limits. * @param {string} filePath - Path to the markdown file to read * @param {Object} [options={}] - Configuration options for link following @@ -190,4 +194,82 @@ function readMarkdownWithLinks(filePath, options = {}) { return combineContent(result); } -module.exports = readMarkdownWithLinks; \ No newline at end of file +module.exports = readMarkdownWithLinks; + + + + +// ============================================================================ +// TESTS (for local development only) +// ============================================================================ +if (require.main === module) { + const fs = require('fs'); + + function assert(condition, message) { + if (!condition) { console.error(`โŒ ${message}`); process.exit(1); } + console.log(`โœ… ${message}`); + } + + // Setup + fs.mkdirSync('./test-files/sub', { recursive: true }); + fs.writeFileSync('./test-files/main.md', '# Main\n[Related](./related.md)\n[Another](./another.md)\n[External](https://example.com)'); + fs.writeFileSync('./test-files/related.md', '# Related\n[Sub](./sub/subdoc.md)'); + fs.writeFileSync('./test-files/another.md', '# Another'); + fs.writeFileSync('./test-files/sub/subdoc.md', '# Sub\n[Main](../main.md)'); + + console.log('๐Ÿงช Running tests\n'); + + // Test 1: Basic reading + let r = readMarkdown('./test-files/main.md', { followLinks: false }); + assert(r.content?.includes('# Main'), 'Basic file reading'); + + // Test 2: Link following + r = readMarkdown('./test-files/main.md', { maxDepth: 2 }); + console.log(r.linkedFiles[0]) + assert(r.linkedFiles.length === 2, 'Follows 2 links'); + assert(r.linkedFiles[0].linkedFiles.length === 1, 'Nested link following'); + + // Test 3: Circular reference + r = readMarkdown('./test-files/main.md', { maxDepth: 5 }); + const circularRef = r.linkedFiles[0].linkedFiles[0].linkedFiles[0]; + assert(circularRef?.error === 'Circular reference detected', 'Circular reference detection'); + + // Test 4: Depth limit + r = readMarkdown('./test-files/main.md', { maxDepth: 1 }); + assert(r.linkedFiles[0].linkedFiles.length === 0, 'Depth limit respected'); + + // Test 5: Non-existent file + r = readMarkdown('./test-files/missing.md'); + assert(r.error === 'File not found or could not be read', 'Non-existent file handling'); + + // Test 6: Combined output + const combined = readMarkdownWithLinks('./test-files/main.md', { maxDepth: 1 }); + assert(combined.includes('=== main.md ==='), 'Combined format includes headers'); + assert(combined.includes(' === related.md ==='), 'Nested files indented'); + + // Test 7: Path traversal blocked + r = readMarkdown('../../../etc/passwd'); + assert(r.content === null, 'Path traversal blocked'); + assert(r.error === 'File not found or could not be read', 'Path traversal returns error'); + + // Test 8: Absolute path blocked + const content1 = readFile('/etc/passwd'); + assert(content1 === null, 'Absolute Unix path blocked'); + + const content2 = readFile('C:\\Windows\\System32\\config'); + assert(content2 === null, 'Absolute Windows path blocked'); + + // Test 9: Empty file handling + fs.writeFileSync('./test-files/empty.md', ''); + r = readMarkdown('./test-files/empty.md'); + assert(r.content === '', 'Empty file handled'); + assert(r.linkedFiles.length === 0, 'Empty file has no links'); + + // Test 10: Self-referencing link + fs.writeFileSync('./test-files/self.md', '# Self\n[Self](./self.md)'); + r = readMarkdown('./test-files/self.md', { maxDepth: 3 }); + assert(r.linkedFiles[0].error === 'Circular reference detected', 'Self-reference detected'); + + console.log('\n๐ŸŽ‰ All tests passed!'); + fs.rmSync('./test-files', { recursive: true }); +} \ No newline at end of file diff --git a/plugins/filters/readMarkdownWithLinks/test.js b/plugins/filters/readMarkdownWithLinks/test.js deleted file mode 100644 index d552e2a4..00000000 --- a/plugins/filters/readMarkdownWithLinks/test.js +++ /dev/null @@ -1,245 +0,0 @@ -const readMarkdownWithLinks = require('./index.js'); - -const fs = require('fs'); -const path = require('path'); - -// Copy the internal functions for testing -function extractInternalLinks(content, basePath) { - const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; - const internalLinks = []; - let match; - - while ((match = linkRegex.exec(content)) !== null) { - const linkText = match[1]; - const linkPath = match[2]; - - if (!linkPath.startsWith('http') && linkPath.endsWith('.md')) { - const resolvedPath = path.resolve(basePath, linkPath); - internalLinks.push({ - text: linkText, - path: linkPath, - resolvedPath: resolvedPath - }); - } - } - - return internalLinks; -} - -function readMarkdown(filePath, options = {}) { - return readMarkdownWithLinks(filePath, { ...options, structured: true }); -} - -// Simple assertion function -function assert(condition, message) { - if (!condition) { - console.error(`โŒ FAIL: ${message}`); - process.exit(1); - } else { - console.log(`โœ… PASS: ${message}`); - } -} - -// Test suite -function runTests() { - console.log('๐Ÿงช Running GitStream Read Markdown Plugin Tests\n'); - - // Setup test files - setupTestFiles(); - - // Test 1: Basic file reading - testBasicFileReading(); - - // Test 2: Link extraction - testLinkExtraction(); - - // Test 3: Link following - testLinkFollowing(); - - // Test 4: Circular reference detection - testCircularReferenceDetection(); - - // Test 5: Depth limit - testDepthLimit(); - - // Test 6: Non-existent file handling - testNonExistentFile(); - - // Test 7: Combined content format - testCombinedContentFormat(); - - console.log('\n๐ŸŽ‰ All tests passed!'); -} - -function setupTestFiles() { - console.log('๐Ÿ“ Setting up test files...'); - - // Main file - const mainContent = `# Main Document - -This is the main document. - -See also: -- [Related document](./related.md) -- [Another document](./another.md) -- [External link](https://example.com) - -## Main Content -This is some main content. -`; - - // Related file - const relatedContent = `# Related Document - -This document is related to the main one. - -Check out [Sub document](./sub/subdoc.md) for more details. - -## Related Content -Some related information here. -`; - - // Another file - const anotherContent = `# Another Document - -This is another document. - -## Another Content -More information in this document. -`; - - // Sub document - const subContent = `# Sub Document - -This is a sub document. - -Back to [main](../main.md) document. - -## Sub Content -Sub document information. -`; - - // Create test directory structure - fs.mkdirSync('./test-files', { recursive: true }); - fs.mkdirSync('./test-files/sub', { recursive: true }); - - fs.writeFileSync('./test-files/main.md', mainContent); - fs.writeFileSync('./test-files/related.md', relatedContent); - fs.writeFileSync('./test-files/another.md', anotherContent); - fs.writeFileSync('./test-files/sub/subdoc.md', subContent); - - console.log('โœ… Test files created\n'); -} - -function testBasicFileReading() { - console.log('๐Ÿ” Test 1: Basic file reading'); - - const result = readMarkdown('./test-files/main.md', { followLinks: false }); - - assert(result.content !== null, 'Should read file content'); - assert(result.content.includes('# Main Document'), 'Should contain main document title'); - assert(result.error === null, 'Should have no errors'); - assert(result.linkedFiles.length === 0, 'Should not follow links when followLinks=false'); - console.log(''); -} - -function testLinkExtraction() { - console.log('๐Ÿ”— Test 2: Link extraction'); - - const content = `# Test - -See [Related](./related.md) and [Another](./another.md). -Also check [External](https://example.com) and [Non-markdown](./file.txt). -`; - - const links = extractInternalLinks(content, './test-files'); - - assert(links.length === 2, 'Should extract exactly 2 internal markdown links'); - assert(links[0].text === 'Related', 'First link text should be "Related"'); - assert(links[0].path === './related.md', 'First link path should be "./related.md"'); - assert(links[1].text === 'Another', 'Second link text should be "Another"'); - assert(links[1].path === './another.md', 'Second link path should be "./another.md"'); - console.log(''); -} - -function testLinkFollowing() { - console.log('๐Ÿšถ Test 3: Link following'); - - const result = readMarkdown('./test-files/main.md', { followLinks: true, maxDepth: 2 }); - - assert(result.linkedFiles.length === 2, 'Should follow 2 internal links from main.md'); - - const relatedFile = result.linkedFiles.find(f => f.linkText === 'Related document'); - const anotherFile = result.linkedFiles.find(f => f.linkText === 'Another document'); - - assert(relatedFile !== undefined, 'Should find related document'); - assert(anotherFile !== undefined, 'Should find another document'); - assert(relatedFile.content.includes('# Related Document'), 'Related file should have correct content'); - assert(anotherFile.content.includes('# Another Document'), 'Another file should have correct content'); - - // Check that related.md's links are also followed - assert(relatedFile.linkedFiles.length === 1, 'Related document should have 1 linked file'); - assert(relatedFile.linkedFiles[0].linkText === 'Sub document', 'Should follow sub document link'); - console.log(''); -} - -function testCircularReferenceDetection() { - console.log('๐Ÿ”„ Test 4: Circular reference detection'); - - const result = readMarkdown('./test-files/main.md', { followLinks: true, maxDepth: 5 }); - - // Navigate to the circular reference: main -> related -> sub -> main - const relatedFile = result.linkedFiles.find(f => f.linkText === 'Related document'); - const subFile = relatedFile.linkedFiles[0]; - const circularRef = subFile.linkedFiles[0]; - - assert(circularRef.error === 'Circular reference detected', 'Should detect circular reference'); - assert(circularRef.content === null, 'Circular reference should have null content'); - console.log(''); -} - -function testDepthLimit() { - console.log('๐Ÿ“ Test 5: Depth limit'); - - const result = readMarkdown('./test-files/main.md', { followLinks: true, maxDepth: 1 }); - - assert(result.linkedFiles.length === 2, 'Should follow first level links'); - - const relatedFile = result.linkedFiles.find(f => f.linkText === 'Related document'); - assert(relatedFile.linkedFiles.length === 0, 'Should not follow second level links due to depth limit'); - assert(relatedFile.depthLimitReached === true, 'Should indicate depth limit reached'); - console.log(''); -} - -function testNonExistentFile() { - console.log('โŒ Test 6: Non-existent file handling'); - - const result = readMarkdown('./test-files/nonexistent.md'); - - assert(result.content === null, 'Should have null content for non-existent file'); - assert(result.error === 'File not found or could not be read', 'Should have appropriate error message'); - assert(result.linkedFiles.length === 0, 'Should have no linked files'); - console.log(''); -} - -function testCombinedContentFormat() { - console.log('๐Ÿ“„ Test 7: Combined content format'); - - const combined = readMarkdownWithLinks('./test-files/main.md', { maxDepth: 1 }); - - assert(combined.includes('=== main.md ==='), 'Should include main file header'); - assert(combined.includes('=== related.md ==='), 'Should include related file header'); - assert(combined.includes('=== another.md ==='), 'Should include another file header'); - assert(combined.includes('# Main Document'), 'Should include main content'); - assert(combined.includes('# Related Document'), 'Should include related content'); - assert(combined.includes('# Another Document'), 'Should include another content'); - - // Check indentation for nested files - const lines = combined.split('\n'); - const relatedHeaderLine = lines.find(line => line.includes('=== related.md ===')); - assert(relatedHeaderLine.startsWith(' '), 'Related file should be indented'); - console.log(''); -} - -// Run the tests -runTests(); \ No newline at end of file From f90b806d9d3f1a733d46a2837646681e4d41370e Mon Sep 17 00:00:00 2001 From: Vincent Lussenburg Date: Wed, 3 Dec 2025 09:25:08 -0800 Subject: [PATCH 3/5] Update README.md --- plugins/filters/readMarkdownWithLinks/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/filters/readMarkdownWithLinks/README.md b/plugins/filters/readMarkdownWithLinks/README.md index dacc88c3..81a782a7 100644 --- a/plugins/filters/readMarkdownWithLinks/README.md +++ b/plugins/filters/readMarkdownWithLinks/README.md @@ -12,10 +12,10 @@ The main use case for this plugin is enhancing LinearB AI code reviews with comp ### Basic Usage ```yaml guidelines: | - {{ "REVIEW_RULES.md" | readMarkdownWithLinks }} + {{ "REVIEW_RULES.md" | readMarkdownWithLinks | dump }} Additional Context: - {{ "README.md" | readMarkdownWithLinks(maxDepth=2) }} + {{ "README.md" | readMarkdownWithLinks(maxDepth=2) | dump }} ``` ## Configuration Options @@ -62,4 +62,4 @@ Content of main document... -[Download Source Code](https://github.com/linear-b/gitstream/tree/main/plugins/filters/readMarkdownWithLinks) \ No newline at end of file +[Download Source Code](https://github.com/linear-b/gitstream/tree/main/plugins/filters/readMarkdownWithLinks) From 99eaf8e2d5dd38a657271fd0a9ca4feac9f5d552 Mon Sep 17 00:00:00 2001 From: Vincent Lussenburg Date: Wed, 3 Dec 2025 09:26:45 -0800 Subject: [PATCH 4/5] Remove self-referencing link test case Removed test for self-referencing link in readMarkdown function. --- plugins/filters/readMarkdownWithLinks/index.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/plugins/filters/readMarkdownWithLinks/index.js b/plugins/filters/readMarkdownWithLinks/index.js index 9359d599..8ce78848 100644 --- a/plugins/filters/readMarkdownWithLinks/index.js +++ b/plugins/filters/readMarkdownWithLinks/index.js @@ -265,11 +265,6 @@ if (require.main === module) { assert(r.content === '', 'Empty file handled'); assert(r.linkedFiles.length === 0, 'Empty file has no links'); - // Test 10: Self-referencing link - fs.writeFileSync('./test-files/self.md', '# Self\n[Self](./self.md)'); - r = readMarkdown('./test-files/self.md', { maxDepth: 3 }); - assert(r.linkedFiles[0].error === 'Circular reference detected', 'Self-reference detected'); - console.log('\n๐ŸŽ‰ All tests passed!'); fs.rmSync('./test-files', { recursive: true }); -} \ No newline at end of file +} From 10513c77167d38f4c9dac1abd5367d1338a54185 Mon Sep 17 00:00:00 2001 From: Vincent Lussenburg Date: Wed, 3 Dec 2025 09:54:10 -0800 Subject: [PATCH 5/5] Update read_markdown_with_links.cm --- .../read_markdown_with_links.cm | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/plugins/filters/readMarkdownWithLinks/read_markdown_with_links.cm b/plugins/filters/readMarkdownWithLinks/read_markdown_with_links.cm index 46c203c1..8b5cd26a 100644 --- a/plugins/filters/readMarkdownWithLinks/read_markdown_with_links.cm +++ b/plugins/filters/readMarkdownWithLinks/read_markdown_with_links.cm @@ -1,5 +1,5 @@ # -*- mode: yaml -*- -# Example GitStream configuration using readMarkdownWithLinks for LinearB AI code reviews +# Example gitStream configuration using readMarkdownWithLinks for LinearB AI code reviews # This shows how to enhance AI code reviews with comprehensive documentation context manifest: @@ -70,24 +70,3 @@ automations: Architecture Documentation: {{ "docs/ARCHITECTURE.md" | readMarkdownWithLinks(maxDepth=2) | dump }} - - # First-time contributor guidance - enhanced_review_new_contributors: - if: - - {{ not pr.draft }} - - {{ pr.author_is_new_contributor }} - run: - - action: code-review@v1 - args: - guidelines: | - Welcome! Here are our code review guidelines with full context: - {{ "REVIEW_RULES.md" | readMarkdownWithLinks | dump }} - - New Contributor Guide: - {{ "docs/ONBOARDING.md" | readMarkdownWithLinks | dump }} - - Project Overview: - {{ "README.md" | readMarkdownWithLinks(maxDepth=1) | dump }} - - Contributing Guidelines: - {{ "CONTRIBUTING.md" | readMarkdownWithLinks(maxDepth=1) | dump }} \ No newline at end of file