| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { createLogger } from '@automaker/utils'; |
| import type { SpecOutput } from '@automaker/types'; |
|
|
| const logger = createLogger('XmlExtractor'); |
|
|
| |
| |
| |
| export interface ImplementedFeature { |
| name: string; |
| description: string; |
| file_locations?: string[]; |
| } |
|
|
| |
| |
| |
| export interface XmlExtractorLogger { |
| debug: (message: string, ...args: unknown[]) => void; |
| warn?: (message: string, ...args: unknown[]) => void; |
| } |
|
|
| |
| |
| |
| export interface ExtractXmlOptions { |
| |
| logger?: XmlExtractorLogger; |
| } |
|
|
| |
| |
| |
| |
| export function escapeXml(str: string | undefined | null): string { |
| if (str == null) { |
| return ''; |
| } |
| return str |
| .replace(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"') |
| .replace(/'/g, '''); |
| } |
|
|
| |
| |
| |
| export function unescapeXml(str: string): string { |
| return str |
| .replace(/'/g, "'") |
| .replace(/"/g, '"') |
| .replace(/>/g, '>') |
| .replace(/</g, '<') |
| .replace(/&/g, '&'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function extractXmlSection( |
| xmlContent: string, |
| tagName: string, |
| options: ExtractXmlOptions = {} |
| ): string | null { |
| const log = options.logger || logger; |
|
|
| const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'i'); |
| const match = xmlContent.match(regex); |
|
|
| if (match) { |
| log.debug(`Extracted <${tagName}> section`); |
| return match[1]; |
| } |
|
|
| log.debug(`Section <${tagName}> not found`); |
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function extractXmlElements( |
| xmlContent: string, |
| tagName: string, |
| options: ExtractXmlOptions = {} |
| ): string[] { |
| const log = options.logger || logger; |
| const values: string[] = []; |
|
|
| const regex = new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`, 'g'); |
| const matches = xmlContent.matchAll(regex); |
|
|
| for (const match of matches) { |
| values.push(unescapeXml(match[1].trim())); |
| } |
|
|
| log.debug(`Extracted ${values.length} <${tagName}> elements`); |
| return values; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function extractImplementedFeatures( |
| specContent: string, |
| options: ExtractXmlOptions = {} |
| ): ImplementedFeature[] { |
| const log = options.logger || logger; |
| const features: ImplementedFeature[] = []; |
|
|
| |
| const implementedSection = extractXmlSection(specContent, 'implemented_features', options); |
|
|
| if (!implementedSection) { |
| log.debug('No implemented_features section found'); |
| return features; |
| } |
|
|
| |
| const featureRegex = /<feature>([\s\S]*?)<\/feature>/g; |
| const featureMatches = implementedSection.matchAll(featureRegex); |
|
|
| for (const featureMatch of featureMatches) { |
| const featureContent = featureMatch[1]; |
|
|
| |
| const nameMatch = featureContent.match(/<name>([\s\S]*?)<\/name>/); |
| const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : ''; |
|
|
| |
| const descMatch = featureContent.match(/<description>([\s\S]*?)<\/description>/); |
| const description = descMatch ? unescapeXml(descMatch[1].trim()) : ''; |
|
|
| |
| const locationsSection = extractXmlSection(featureContent, 'file_locations', options); |
| const file_locations = locationsSection |
| ? extractXmlElements(locationsSection, 'location', options) |
| : undefined; |
|
|
| if (name) { |
| features.push({ |
| name, |
| description, |
| ...(file_locations && file_locations.length > 0 ? { file_locations } : {}), |
| }); |
| } |
| } |
|
|
| log.debug(`Extracted ${features.length} implemented features`); |
| return features; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function extractImplementedFeatureNames( |
| specContent: string, |
| options: ExtractXmlOptions = {} |
| ): string[] { |
| const features = extractImplementedFeatures(specContent, options); |
| return features.map((f) => f.name); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function featureToXml(feature: ImplementedFeature, indent: string = ' '): string { |
| const i2 = indent.repeat(2); |
| const i3 = indent.repeat(3); |
| const i4 = indent.repeat(4); |
|
|
| let xml = `${i2}<feature> |
| ${i3}<name>${escapeXml(feature.name)}</name> |
| ${i3}<description>${escapeXml(feature.description)}</description>`; |
|
|
| if (feature.file_locations && feature.file_locations.length > 0) { |
| xml += ` |
| ${i3}<file_locations> |
| ${feature.file_locations.map((loc) => `${i4}<location>${escapeXml(loc)}</location>`).join('\n')} |
| ${i3}</file_locations>`; |
| } |
|
|
| xml += ` |
| ${i2}</feature>`; |
|
|
| return xml; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string { |
| return features.map((f) => featureToXml(f, indent)).join('\n'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function updateImplementedFeaturesSection( |
| specContent: string, |
| newFeatures: ImplementedFeature[], |
| options: ExtractXmlOptions = {} |
| ): string { |
| const log = options.logger || logger; |
| const indent = ' '; |
|
|
| |
| const newSectionContent = featuresToXml(newFeatures, indent); |
|
|
| |
| const newSection = `<implemented_features> |
| ${newSectionContent} |
| ${indent}</implemented_features>`; |
|
|
| |
| const sectionRegex = /<implemented_features>[\s\S]*?<\/implemented_features>/; |
|
|
| if (sectionRegex.test(specContent)) { |
| log.debug('Replacing existing implemented_features section'); |
| return specContent.replace(sectionRegex, newSection); |
| } |
|
|
| |
| const coreCapabilitiesEnd = '</core_capabilities>'; |
| const insertIndex = specContent.indexOf(coreCapabilitiesEnd); |
|
|
| if (insertIndex !== -1) { |
| const insertPosition = insertIndex + coreCapabilitiesEnd.length; |
| log.debug('Inserting implemented_features after core_capabilities'); |
| return ( |
| specContent.slice(0, insertPosition) + |
| '\n\n' + |
| indent + |
| newSection + |
| specContent.slice(insertPosition) |
| ); |
| } |
|
|
| |
| const projectSpecEnd = '</project_specification>'; |
| const fallbackIndex = specContent.indexOf(projectSpecEnd); |
|
|
| if (fallbackIndex !== -1) { |
| log.debug('Inserting implemented_features before </project_specification>'); |
| return ( |
| specContent.slice(0, fallbackIndex) + |
| indent + |
| newSection + |
| '\n' + |
| specContent.slice(fallbackIndex) |
| ); |
| } |
|
|
| log.warn?.('Could not find appropriate insertion point for implemented_features'); |
| log.debug('Could not find appropriate insertion point for implemented_features'); |
| return specContent; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function addImplementedFeature( |
| specContent: string, |
| newFeature: ImplementedFeature, |
| options: ExtractXmlOptions = {} |
| ): string { |
| const log = options.logger || logger; |
|
|
| |
| const existingFeatures = extractImplementedFeatures(specContent, options); |
|
|
| |
| const isDuplicate = existingFeatures.some( |
| (f) => f.name.toLowerCase() === newFeature.name.toLowerCase() |
| ); |
|
|
| if (isDuplicate) { |
| log.debug(`Feature "${newFeature.name}" already exists, skipping`); |
| return specContent; |
| } |
|
|
| |
| const updatedFeatures = [...existingFeatures, newFeature]; |
|
|
| log.debug(`Adding feature "${newFeature.name}"`); |
| return updateImplementedFeaturesSection(specContent, updatedFeatures, options); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function removeImplementedFeature( |
| specContent: string, |
| featureName: string, |
| options: ExtractXmlOptions = {} |
| ): string { |
| const log = options.logger || logger; |
|
|
| |
| const existingFeatures = extractImplementedFeatures(specContent, options); |
|
|
| |
| const updatedFeatures = existingFeatures.filter( |
| (f) => f.name.toLowerCase() !== featureName.toLowerCase() |
| ); |
|
|
| if (updatedFeatures.length === existingFeatures.length) { |
| log.debug(`Feature "${featureName}" not found, no changes made`); |
| return specContent; |
| } |
|
|
| log.debug(`Removing feature "${featureName}"`); |
| return updateImplementedFeaturesSection(specContent, updatedFeatures, options); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function updateImplementedFeature( |
| specContent: string, |
| featureName: string, |
| updates: Partial<ImplementedFeature>, |
| options: ExtractXmlOptions = {} |
| ): string { |
| const log = options.logger || logger; |
|
|
| |
| const existingFeatures = extractImplementedFeatures(specContent, options); |
|
|
| |
| let found = false; |
| const updatedFeatures = existingFeatures.map((f) => { |
| if (f.name.toLowerCase() === featureName.toLowerCase()) { |
| found = true; |
| return { |
| ...f, |
| ...updates, |
| |
| name: updates.name ?? f.name, |
| }; |
| } |
| return f; |
| }); |
|
|
| if (!found) { |
| log.debug(`Feature "${featureName}" not found, no changes made`); |
| return specContent; |
| } |
|
|
| log.debug(`Updating feature "${featureName}"`); |
| return updateImplementedFeaturesSection(specContent, updatedFeatures, options); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function hasImplementedFeature( |
| specContent: string, |
| featureName: string, |
| options: ExtractXmlOptions = {} |
| ): boolean { |
| const features = extractImplementedFeatures(specContent, options); |
| return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase()); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function toSpecOutputFeatures( |
| features: ImplementedFeature[] |
| ): SpecOutput['implemented_features'] { |
| return features.map((f) => ({ |
| name: f.name, |
| description: f.description, |
| ...(f.file_locations && f.file_locations.length > 0 |
| ? { file_locations: f.file_locations } |
| : {}), |
| })); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function fromSpecOutputFeatures( |
| specFeatures: SpecOutput['implemented_features'] |
| ): ImplementedFeature[] { |
| return specFeatures.map((f) => ({ |
| name: f.name, |
| description: f.description, |
| ...(f.file_locations && f.file_locations.length > 0 |
| ? { file_locations: f.file_locations } |
| : {}), |
| })); |
| } |
|
|
| |
| |
| |
| export interface RoadmapPhase { |
| name: string; |
| status: string; |
| description?: string; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function extractTechnologyStack( |
| specContent: string, |
| options: ExtractXmlOptions = {} |
| ): string[] { |
| const log = options.logger || logger; |
|
|
| const techSection = extractXmlSection(specContent, 'technology_stack', options); |
| if (!techSection) { |
| log.debug('No technology_stack section found'); |
| return []; |
| } |
|
|
| const technologies = extractXmlElements(techSection, 'technology', options); |
| log.debug(`Extracted ${technologies.length} technologies`); |
| return technologies; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function updateTechnologyStack( |
| specContent: string, |
| technologies: string[], |
| options: ExtractXmlOptions = {} |
| ): string { |
| const log = options.logger || logger; |
| const indent = ' '; |
| const i2 = indent.repeat(2); |
|
|
| |
| const techXml = technologies |
| .map((t) => `${i2}<technology>${escapeXml(t)}</technology>`) |
| .join('\n'); |
| const newSection = `<technology_stack>\n${techXml}\n${indent}</technology_stack>`; |
|
|
| |
| const sectionRegex = /<technology_stack>[\s\S]*?<\/technology_stack>/; |
|
|
| if (sectionRegex.test(specContent)) { |
| log.debug('Replacing existing technology_stack section'); |
| return specContent.replace(sectionRegex, newSection); |
| } |
|
|
| log.debug('No technology_stack section found to update'); |
| return specContent; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function extractRoadmapPhases( |
| specContent: string, |
| options: ExtractXmlOptions = {} |
| ): RoadmapPhase[] { |
| const log = options.logger || logger; |
| const phases: RoadmapPhase[] = []; |
|
|
| const roadmapSection = extractXmlSection(specContent, 'implementation_roadmap', options); |
| if (!roadmapSection) { |
| log.debug('No implementation_roadmap section found'); |
| return phases; |
| } |
|
|
| |
| const phaseRegex = /<phase>([\s\S]*?)<\/phase>/g; |
| const phaseMatches = roadmapSection.matchAll(phaseRegex); |
|
|
| for (const phaseMatch of phaseMatches) { |
| const phaseContent = phaseMatch[1]; |
|
|
| const nameMatch = phaseContent.match(/<name>([\s\S]*?)<\/name>/); |
| const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : ''; |
|
|
| const statusMatch = phaseContent.match(/<status>([\s\S]*?)<\/status>/); |
| const status = statusMatch ? unescapeXml(statusMatch[1].trim()) : 'pending'; |
|
|
| const descMatch = phaseContent.match(/<description>([\s\S]*?)<\/description>/); |
| const description = descMatch ? unescapeXml(descMatch[1].trim()) : undefined; |
|
|
| if (name) { |
| phases.push({ name, status, description }); |
| } |
| } |
|
|
| log.debug(`Extracted ${phases.length} roadmap phases`); |
| return phases; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function updateRoadmapPhaseStatus( |
| specContent: string, |
| phaseName: string, |
| newStatus: string, |
| options: ExtractXmlOptions = {} |
| ): string { |
| const log = options.logger || logger; |
|
|
| |
| |
| const phaseRegex = new RegExp( |
| `(<phase>\\s*<name>\\s*${escapeXml(phaseName)}\\s*<\\/name>\\s*<status>)[\\s\\S]*?(<\\/status>)`, |
| 'i' |
| ); |
|
|
| if (phaseRegex.test(specContent)) { |
| log.debug(`Updating phase "${phaseName}" status to "${newStatus}"`); |
| return specContent.replace(phaseRegex, `$1${escapeXml(newStatus)}$2`); |
| } |
|
|
| log.debug(`Phase "${phaseName}" not found`); |
| return specContent; |
| } |
|
|