/** * Core utilities for the watch extension. * Separated for testability. */ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import type { AICommentMode, ParsedComment } from "./types.js"; /** * Create a unique key for a comment. */ export function getCommentKey(comment: ParsedComment): string { const hash = crypto.createHash("md5").update(comment.rawLines.join("\n")).digest("hex"); return `${comment.filePath}:${comment.lineNumber}:${hash}`; } export const DEFAULT_IGNORED_PATTERNS = [/\.git/, /node_modules/, /dist/, /build/, /\.pi/]; // Comment styles: #, //, -- // Position: start OR end of line // Case insensitive // Variants: AI!, AI?, AI-, AI:, AI (with or without punctuation) const COMMENT_PATTERNS = [ // AI at end: // do this ai!, # ask why AI?, // text AI: /^(?:#|\/\/|--)\s*(.+?)\s*(ai[!?-]?|ai)\s*[:\s]*$/i, // AI at start: // ai! do this, # AI? why, // AI: do this /^(?:#|\/\/|--)\s*(ai[!?-]?|ai)[:\s]*\s*(.+)$/i, ]; const AI_IGNORE_FILE_PATTERNS = [ /^(?:#|\/\/|--)\s*(?:.+?\s+)?ai-\s*(?::.*)?$/i, /^$/i, ]; function findCommentStart(line: string): number { const candidates: number[] = []; for (const marker of ["//", "#", "--"]) { let index = line.indexOf(marker); while (index >= 0) { const prefix = line.slice(0, index); const commentSegment = line.slice(index).trim(); if (prefix.trim().length === 0 || /\s$/.test(prefix)) { if (COMMENT_PATTERNS.some((pattern) => pattern.test(commentSegment))) { candidates.push(index); } } index = line.indexOf(marker, index + marker.length); } } return candidates.length > 0 ? Math.min(...candidates) : -1; } function getCommentSegment(line: string): string | null { const commentStart = findCommentStart(line); return commentStart >= 0 ? line.slice(commentStart).trim() : null; } /** * Check if a line contains an AI comment. * Returns the detected marker mode. */ export function parseAIComment(line: string): AICommentMode | null { const trimmedLine = line.trim(); if (AI_IGNORE_FILE_PATTERNS.some((pattern) => pattern.test(trimmedLine))) { return "ignore"; } const commentSegment = getCommentSegment(line); if (!commentSegment) return null; for (const pattern of COMMENT_PATTERNS) { const match = commentSegment.match(pattern); if (!match) continue; const marker = match.find((part) => /^ai[!?-]?$/i.test(part)); if (marker?.toLowerCase() === "ai!") return "edit"; if (marker?.toLowerCase() === "ai?") return "question"; if (marker?.toLowerCase() === "ai-") return "ignore"; return "collect"; } return null; } /** * Check if a path should be ignored based on ignore patterns. */ export function shouldIgnorePath(filePath: string, ignoredPatterns: RegExp[]): boolean { return ignoredPatterns.some((pattern) => pattern.test(filePath)); } export function hasAIIgnoreFileMarker(content: string): boolean { return content .split("\n") .some((line) => AI_IGNORE_FILE_PATTERNS.some((pattern) => pattern.test(line.trim()))); } export function readPiWatchIgnorePatterns(cwd: string): string[] { try { const content = fs.readFileSync(path.join(cwd, ".piwatchignore"), "utf-8"); return content .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("#")); } catch { return []; } } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function ignorePatternToRegExp(pattern: string): RegExp { let normalized = pattern.replace(/\\/g, "/").replace(/^\.\//, ""); if (normalized.endsWith("/")) { normalized += "**"; } let source = ""; for (let i = 0; i < normalized.length; i++) { const char = normalized[i]; if (char === "*") { if (normalized[i + 1] === "*") { source += ".*"; i++; } else { source += "[^/]*"; } } else { source += escapeRegExp(char); } } return new RegExp(`(^|/)${source}$`); } export function matchesPiWatchIgnore(filePath: string, cwd: string, patterns: string[]): boolean { const relativePath = path.relative(cwd, filePath).replace(/\\/g, "/"); return patterns.some((pattern) => ignorePatternToRegExp(pattern).test(relativePath)); } function mergeCommentMode(current: AICommentMode, next: AICommentMode): AICommentMode { if (current === "ignore" || next === "ignore") return "ignore"; if (current === "edit" || next === "edit") return "edit"; if (current === "question" || next === "question") return "question"; return "collect"; } function createParsedComment( filePath: string, lineNumber: number, rawLines: string[], commentStartColumns: number[], mode: AICommentMode ): ParsedComment { return { filePath, lineNumber, rawLines: [...rawLines], commentStartColumns: [...commentStartColumns], hasTrigger: mode === "edit", mode, }; } /** * Find all AI comments in file content with grouping. * Consecutive AI comment lines are grouped together. */ export function parseCommentsInFile(filePath: string, content: string): ParsedComment[] { const lines = content.split("\n"); const comments: ParsedComment[] = []; let currentGroup: string[] = []; let currentGroupCommentStartColumns: number[] = []; let groupStartLine = 0; let groupMode: AICommentMode = "collect"; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const mode = parseAIComment(line); if (mode !== null) { if (currentGroup.length === 0) { groupStartLine = i + 1; } currentGroup.push(line); // Store raw line with whitespace preserved currentGroupCommentStartColumns.push(findCommentStart(line)); groupMode = mergeCommentMode(groupMode, mode); } else { // End of group if (currentGroup.length > 0) { comments.push( createParsedComment( filePath, groupStartLine, currentGroup, currentGroupCommentStartColumns, groupMode ) ); currentGroup = []; currentGroupCommentStartColumns = []; groupMode = "collect"; } } } // Handle group at end of file if (currentGroup.length > 0) { comments.push( createParsedComment( filePath, groupStartLine, currentGroup, currentGroupCommentStartColumns, groupMode ) ); } return comments; } /** * Read a file and parse AI comments. */ export function readFileAndParseComments(filePath: string): { content: string; comments: ParsedComment[]; } | null { try { const content = fs.readFileSync(filePath, "utf-8"); const comments = parseCommentsInFile(filePath, content); return { content, comments }; } catch { return null; } } function cleanupAICommentLine(line: string, commentStart: number): string | null { if (commentStart < 0) return line; const codePrefix = line.slice(0, commentStart); if (codePrefix.trim().length === 0) { return null; } return codePrefix.trimEnd(); } export function removeAICommentsFromFiles(comments: ParsedComment[]): void { const commentsByFile = new Map(); for (const comment of comments) { const fileComments = commentsByFile.get(comment.filePath) ?? []; fileComments.push(comment); commentsByFile.set(comment.filePath, fileComments); } for (const [filePath, fileComments] of commentsByFile) { const content = fs.readFileSync(filePath, "utf-8"); const lines = content.split("\n"); const lineReplacements = new Map(); for (const comment of fileComments) { for (let i = 0; i < comment.rawLines.length; i++) { const lineIndex = comment.lineNumber - 1 + i; if (lines[lineIndex] === comment.rawLines[i]) { lineReplacements.set( lineIndex, cleanupAICommentLine(lines[lineIndex], comment.commentStartColumns[i]) ); } } } const nextLines = lines.flatMap((line, index) => { if (!lineReplacements.has(index)) return [line]; const replacement = lineReplacements.get(index); return replacement === null ? [] : [replacement]; }); fs.writeFileSync(filePath, nextLines.join("\n"), "utf-8"); } } /** * Get relative path from cwd. */ export function getRelativePath(filePath: string, cwd: string): string { const relativePath = path.relative(cwd, filePath); if (!relativePath.startsWith("..")) { return relativePath; } const portablePath = filePath.split(path.sep).join("/"); const projectDirMatch = portablePath.match(/(?:^|\/)(src|app|lib|packages|test|tests)\//); if (projectDirMatch?.index !== undefined) { return portablePath.slice(projectDirMatch.index + 1); } return path.basename(filePath); } /** * Create the user message for the AI agent. */ function appendCommentLocations(message: string, comments: ParsedComment[]): string { for (const comment of comments) { const relativePath = getRelativePath(comment.filePath, process.cwd()); message += `${relativePath}:\n`; for (let i = 0; i < comment.rawLines.length; i++) { const lineNumber = comment.lineNumber + i; message += `${lineNumber}: ${comment.rawLines[i]}\n`; } message += "\n"; } return message.trim(); } export function createAIMessage(comments: ParsedComment[]): string { if (comments.length === 0) { return ""; } let message = "The AI comments below can be found in the code files.\n"; message += "They contain your instructions.\n"; message += "Line numbers are provided for reference.\n"; message += "Rules:\n"; message += "- Only make changes to files and lines that have AI comments.\n"; message += "- If an AI comment is inline after code, apply it to the code on that same line.\n"; message += "- Do not modify any other files or areas of files.\n"; message += "- Follow the instructions in the AI comments strictly.\n"; message += "- Be sure to remove all AI comments from the code during or after the changes.\n"; message += '- After changes are finised say just "Done" and nothing else.\n\n'; return appendCommentLocations(message, comments); } /** * Create the user question message for the AI agent. */ export function createAIQuestionMessage(comments: ParsedComment[]): string { if (comments.length === 0) { return ""; } let message = "The AI? comments below can be found in the code files.\n"; message += "They contain questions or context from the user.\n"; message += "Line numbers are provided for reference.\n"; message += "Rules:\n"; message += "- Answer the user question in chat.\n"; message += "- If an AI? comment is inline after code, answer about the code on that same line.\n"; message += "- Do not modify files or code beyond the processed AI comment cleanup.\n"; message += "- The processed AI comments will be removed from the code after dispatch.\n\n"; return appendCommentLocations(message, comments); } /** * Filter comments to only those with AI! trigger. */ export function filterTriggerComments(comments: ParsedComment[]): ParsedComment[] { return comments.filter((c) => c.hasTrigger); } /** * Check if any comment has a trigger. */ export function hasTriggerComment(comments: ParsedComment[]): boolean { return comments.some((c) => c.hasTrigger); } /** * Check if any comment has a question trigger. */ export function hasQuestionComment(comments: ParsedComment[]): boolean { return comments.some((c) => c.mode === "question"); }