UNPKG

@sudocode-ai/cli

Version:

Git-native spec and issue management CLI for AI-assisted software development

329 lines 12 kB
/** * Markdown parser with frontmatter support */ import matter from "gray-matter"; import * as fs from "fs"; import { getSpec } from "./operations/specs.js"; import { getIssue } from "./operations/issues.js"; import { createFeedbackAnchor } from "./operations/feedback-anchors.js"; /** * Parse markdown file with YAML frontmatter * @param content - Markdown content to parse * @param db - Optional database for validating cross-references * @param outputDir - Optional output directory for loading config metadata */ export function parseMarkdown(content, db, outputDir) { const parsed = matter(content); // Extract cross-references from content const references = extractCrossReferences(parsed.content, db); return { data: parsed.data, content: parsed.content, raw: content, references, }; } /** * Parse markdown file from disk * @param filePath - Path to markdown file * @param db - Optional database for validating cross-references * @param outputDir - Optional output directory for loading config metadata */ export function parseMarkdownFile(filePath, db, outputDir) { const content = fs.readFileSync(filePath, "utf8"); return parseMarkdown(content, db, outputDir); } /** * Convert character index to line number */ function getLineNumber(content, charIndex) { const beforeMatch = content.substring(0, charIndex); return beforeMatch.split("\n").length; } /** * Convert FeedbackAnchor to LocationAnchor (strips tracking fields) */ function feedbackAnchorToLocationAnchor(feedbackAnchor) { return { section_heading: feedbackAnchor.section_heading, section_level: feedbackAnchor.section_level, line_number: feedbackAnchor.line_number, line_offset: feedbackAnchor.line_offset, text_snippet: feedbackAnchor.text_snippet, context_before: feedbackAnchor.context_before, context_after: feedbackAnchor.context_after, content_hash: feedbackAnchor.content_hash, }; } /** * Extract cross-references from markdown content * Supports formats: * - [[i-x7k9]] or [[s-14sh]] - hash-based entity reference * - [[@i-x7k9]] - entity reference with @ prefix (for clarity) * - [[i-x7k9|Display Text]] - with custom display text * - [[i-x7k9]]{ blocks } - with relationship type (shorthand) * - [[i-x7k9]]{ type: blocks } - with relationship type (explicit) * - [[i-x7k9|Display]]{ blocks } - combination of display text and type * * If db is provided, validates references against the database and determines entity type. * Only returns references to entities that actually exist. * * If db is not provided, determines entity type from hash-based ID prefix (i- or s-). */ export function extractCrossReferences(content, db) { const references = []; // Pattern: [[optional-@][entity-id][|display-text]]optional-metadata // Supports hash-based IDs only: // - [[i-x7k9]] or [[s-14sh]] // - [[i-x7k9|Display Text]] // - [[i-x7k9]]{ blocks } // - [[i-x7k9]]{ type: depends-on } const refPattern = /\[\[(@)?([is]-[0-9a-z]{4,8})(?:\|([^\]]+))?\]\](?:\{\s*(?:type:\s*)?([a-z-]+)\s*\})?/gi; let match; while ((match = refPattern.exec(content)) !== null) { const hasAt = match[1] === "@"; const id = match[2]; const displayText = match[3]?.trim(); const relationshipType = match[4]?.trim(); // Create location anchor for this reference let anchor; try { const lineNumber = getLineNumber(content, match.index); const feedbackAnchor = createFeedbackAnchor(content, lineNumber, match.index); anchor = feedbackAnchorToLocationAnchor(feedbackAnchor); } catch (error) { // If anchor creation fails, continue without it anchor = undefined; } if (db) { let entityType = null; try { const spec = getSpec(db, id); if (spec) { entityType = "spec"; } } catch (error) { } if (!entityType) { try { const issue = getIssue(db, id); if (issue) { entityType = "issue"; } } catch (error) { } } if (entityType) { references.push({ match: match[0], id, type: entityType, index: match.index, displayText, relationshipType, anchor, }); } } else { // Determine type from hash-based ID prefix // Hash IDs always use i- for issues, s- for specs const type = id.startsWith("i-") ? "issue" : "spec"; references.push({ match: match[0], id, type, index: match.index, displayText, relationshipType, anchor, }); } } return references; } /** * Stringify frontmatter and content back to markdown */ export function stringifyMarkdown(data, content) { return matter.stringify(content, data); } /** * Update frontmatter in an existing markdown file * Preserves content unchanged */ export function updateFrontmatter(originalContent, updates) { const parsed = matter(originalContent); // Merge updates into existing frontmatter const merged = { ...parsed.data, ...updates, }; // Remove keys with undefined values (allows explicit removal of fields) const newData = Object.fromEntries(Object.entries(merged).filter(([_, value]) => value !== undefined)); return matter.stringify(parsed.content, newData); } /** * Update frontmatter in a file */ export function updateFrontmatterFile(filePath, updates) { const content = fs.readFileSync(filePath, "utf8"); const updated = updateFrontmatter(content, updates); fs.writeFileSync(filePath, updated, "utf8"); } /** * Check if a file has frontmatter */ export function hasFrontmatter(content) { return content.trimStart().startsWith("---"); } /** * Create markdown with frontmatter */ export function createMarkdown(data, content) { return stringifyMarkdown(data, content); } /** * Write markdown file with frontmatter */ export function writeMarkdownFile(filePath, data, content) { const markdown = createMarkdown(data, content); fs.writeFileSync(filePath, markdown, "utf8"); } /** * Remove frontmatter from markdown content */ export function removeFrontmatter(content) { const parsed = matter(content); return parsed.content; } /** * Get only frontmatter data from markdown */ export function getFrontmatter(content) { const parsed = matter(content); return parsed.data; } /** * Parse feedback section from issue markdown content * Looks for "## Spec Feedback Provided" section */ export function parseFeedbackSection(content) { const feedback = []; // Look for "## Spec Feedback Provided" section const feedbackSectionMatch = content.match(/^## Spec Feedback Provided\s*$/m); if (!feedbackSectionMatch) { return feedback; } const startIndex = feedbackSectionMatch.index + feedbackSectionMatch[0].length; // Find the end of this section (next ## heading or end of content) const remainingContent = content.slice(startIndex); const endMatch = remainingContent.match(/^## /m); const sectionContent = endMatch ? remainingContent.slice(0, endMatch.index) : remainingContent; // Parse individual feedback items (### heading for each) const feedbackPattern = /^### (FB-\d+) → ([a-z]+-\d+)(?: \((.*?)\))?\s*\n\*\*Type:\*\* (.+?)\s*\n\*\*Location:\*\* (.*?)\s*\n\*\*Status:\*\* (.+?)\s*\n\n([\s\S]*?)(?=\n###|$)/gm; let match; while ((match = feedbackPattern.exec(sectionContent)) !== null) { const [, id, specId, specTitle, type, locationStr, status, content] = match; // Parse location string: "## Section Name, line 45 ✓" or "line 45 ⚠" or "Unknown ✗" const locationMatch = locationStr.match(/(?:(.+?),\s+)?line (\d+)\s*([✓⚠✗])/); const feedbackData = { id, specId, specTitle: specTitle || undefined, type: type.trim(), location: { section: locationMatch?.[1]?.trim(), line: locationMatch?.[2] ? parseInt(locationMatch[2]) : undefined, status: locationMatch?.[3] === "✓" ? "valid" : locationMatch?.[3] === "⚠" ? "relocated" : "stale", }, status: status.trim(), content: content.trim(), createdAt: "", // Would need to parse from content or get from DB }; // Check for resolution const resolutionMatch = content.match(/\*\*Resolution:\*\* (.+)/); if (resolutionMatch) { feedbackData.resolution = resolutionMatch[1].trim(); } feedback.push(feedbackData); } return feedback; } /** * Format feedback data for inclusion in issue markdown */ export function formatFeedbackForIssue(feedback) { if (feedback.length === 0) { return ""; } let output = "\n## Spec Feedback Provided\n\n"; for (const fb of feedback) { // Determine status indicator const statusIndicator = fb.location.status === "valid" ? "✓" : fb.location.status === "relocated" ? "⚠" : "✗"; // Format location let locationStr = ""; if (fb.location.section && fb.location.line) { locationStr = `${fb.location.section}, line ${fb.location.line} ${statusIndicator}`; } else if (fb.location.line) { locationStr = `line ${fb.location.line} ${statusIndicator}`; } else { locationStr = `Unknown ${statusIndicator}`; } const titlePart = fb.specTitle ? ` (${fb.specTitle})` : ""; output += `### ${fb.id}${fb.specId}${titlePart}\n`; output += `**Type:** ${fb.type} \n`; output += `**Location:** ${locationStr} \n`; output += `**Status:** ${fb.status}\n\n`; output += `${fb.content}\n`; if (fb.resolution) { output += `\n**Resolution:** ${fb.resolution}\n`; } output += "\n"; } return output; } /** * Append or update feedback section in issue markdown */ export function updateFeedbackInIssue(issueContent, feedback) { // Remove existing feedback section if present const feedbackSectionMatch = issueContent.match(/^## Spec Feedback Provided\s*$/m); if (feedbackSectionMatch) { const startIndex = feedbackSectionMatch.index; // Find the end of this section (next ## heading or end of content) const remainingContent = issueContent.slice(startIndex); const endMatch = remainingContent.match(/^## /m); if (endMatch && endMatch.index > 0) { // There's another section after feedback const endIndex = startIndex + endMatch.index; issueContent = issueContent.slice(0, startIndex) + issueContent.slice(endIndex); } else { // Feedback section is at the end issueContent = issueContent.slice(0, startIndex); } } // Append new feedback section const feedbackMarkdown = formatFeedbackForIssue(feedback); if (feedbackMarkdown) { // Ensure there's a blank line before the new section issueContent = issueContent.trimEnd() + "\n" + feedbackMarkdown; } return issueContent; } //# sourceMappingURL=markdown.js.map