mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge pull request #527 from AutoMaker-Org/feature/v0.12.0rc-1768598412391-lnp7
feat: implement XML extraction utilities and enhance feature handling
This commit is contained in:
@@ -13,7 +13,7 @@ export { specOutputSchema } from '@automaker/types';
|
||||
* Escape special XML characters
|
||||
* Handles undefined/null values by converting them to empty strings
|
||||
*/
|
||||
function escapeXml(str: string | undefined | null): string {
|
||||
export function escapeXml(str: string | undefined | null): string {
|
||||
if (str == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
465
apps/server/src/lib/xml-extractor.ts
Normal file
465
apps/server/src/lib/xml-extractor.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* XML Extraction Utilities
|
||||
*
|
||||
* Robust XML parsing utilities for extracting and updating sections
|
||||
* from app_spec.txt XML content. Uses regex-based parsing which is
|
||||
* sufficient for our controlled XML structure.
|
||||
*
|
||||
* Note: If more complex XML parsing is needed in the future, consider
|
||||
* using a library like 'fast-xml-parser' or 'xml2js'.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { SpecOutput } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('XmlExtractor');
|
||||
|
||||
/**
|
||||
* Represents an implemented feature extracted from XML
|
||||
*/
|
||||
export interface ImplementedFeature {
|
||||
name: string;
|
||||
description: string;
|
||||
file_locations?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for optional custom logging
|
||||
*/
|
||||
export interface XmlExtractorLogger {
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
warn?: (message: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for XML extraction operations
|
||||
*/
|
||||
export interface ExtractXmlOptions {
|
||||
/** Custom logger (defaults to internal logger) */
|
||||
logger?: XmlExtractorLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special XML characters
|
||||
* Handles undefined/null values by converting them to empty strings
|
||||
*/
|
||||
export function escapeXml(str: string | undefined | null): string {
|
||||
if (str == null) {
|
||||
return '';
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescape XML entities back to regular characters
|
||||
*/
|
||||
export function unescapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the content of a specific XML section
|
||||
*
|
||||
* @param xmlContent - The full XML content
|
||||
* @param tagName - The tag name to extract (e.g., 'implemented_features')
|
||||
* @param options - Optional extraction options
|
||||
* @returns The content between the tags, or null if not found
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all values from repeated XML elements
|
||||
*
|
||||
* @param xmlContent - The XML content to search
|
||||
* @param tagName - The tag name to extract values from
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of extracted values (unescaped)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract implemented features from app_spec.txt XML content
|
||||
*
|
||||
* @param specContent - The full XML content of app_spec.txt
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of implemented features with name, description, and optional file_locations
|
||||
*/
|
||||
export function extractImplementedFeatures(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): ImplementedFeature[] {
|
||||
const log = options.logger || logger;
|
||||
const features: ImplementedFeature[] = [];
|
||||
|
||||
// Match <implemented_features>...</implemented_features> section
|
||||
const implementedSection = extractXmlSection(specContent, 'implemented_features', options);
|
||||
|
||||
if (!implementedSection) {
|
||||
log.debug('No implemented_features section found');
|
||||
return features;
|
||||
}
|
||||
|
||||
// Extract individual feature blocks
|
||||
const featureRegex = /<feature>([\s\S]*?)<\/feature>/g;
|
||||
const featureMatches = implementedSection.matchAll(featureRegex);
|
||||
|
||||
for (const featureMatch of featureMatches) {
|
||||
const featureContent = featureMatch[1];
|
||||
|
||||
// Extract name
|
||||
const nameMatch = featureContent.match(/<name>([\s\S]*?)<\/name>/);
|
||||
const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
|
||||
|
||||
// Extract description
|
||||
const descMatch = featureContent.match(/<description>([\s\S]*?)<\/description>/);
|
||||
const description = descMatch ? unescapeXml(descMatch[1].trim()) : '';
|
||||
|
||||
// Extract file_locations if present
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract only the feature names from implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content of app_spec.txt
|
||||
* @param options - Optional extraction options
|
||||
* @returns Array of feature names
|
||||
*/
|
||||
export function extractImplementedFeatureNames(
|
||||
specContent: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string[] {
|
||||
const features = extractImplementedFeatures(specContent, options);
|
||||
return features.map((f) => f.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate XML for a single implemented feature
|
||||
*
|
||||
* @param feature - The feature to convert to XML
|
||||
* @param indent - The base indentation level (default: 2 spaces)
|
||||
* @returns XML string for the feature
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate XML for an array of implemented features
|
||||
*
|
||||
* @param features - Array of features to convert to XML
|
||||
* @param indent - The base indentation level (default: 2 spaces)
|
||||
* @returns XML string for the implemented_features section content
|
||||
*/
|
||||
export function featuresToXml(features: ImplementedFeature[], indent: string = ' '): string {
|
||||
return features.map((f) => featureToXml(f, indent)).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the implemented_features section in XML content
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param newFeatures - The new features to set
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the new implemented_features section
|
||||
*/
|
||||
export function updateImplementedFeaturesSection(
|
||||
specContent: string,
|
||||
newFeatures: ImplementedFeature[],
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
const indent = ' ';
|
||||
|
||||
// Generate new section content
|
||||
const newSectionContent = featuresToXml(newFeatures, indent);
|
||||
|
||||
// Build the new section
|
||||
const newSection = `<implemented_features>
|
||||
${newSectionContent}
|
||||
${indent}</implemented_features>`;
|
||||
|
||||
// Check if section exists
|
||||
const sectionRegex = /<implemented_features>[\s\S]*?<\/implemented_features>/;
|
||||
|
||||
if (sectionRegex.test(specContent)) {
|
||||
log.debug('Replacing existing implemented_features section');
|
||||
return specContent.replace(sectionRegex, newSection);
|
||||
}
|
||||
|
||||
// If section doesn't exist, try to insert after core_capabilities
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
// As a fallback, insert before </project_specification>
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new feature to the implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param newFeature - The feature to add
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the new feature added
|
||||
*/
|
||||
export function addImplementedFeature(
|
||||
specContent: string,
|
||||
newFeature: ImplementedFeature,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Extract existing features
|
||||
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||
|
||||
// Check for duplicates by name
|
||||
const isDuplicate = existingFeatures.some(
|
||||
(f) => f.name.toLowerCase() === newFeature.name.toLowerCase()
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
log.debug(`Feature "${newFeature.name}" already exists, skipping`);
|
||||
return specContent;
|
||||
}
|
||||
|
||||
// Add the new feature
|
||||
const updatedFeatures = [...existingFeatures, newFeature];
|
||||
|
||||
log.debug(`Adding feature "${newFeature.name}"`);
|
||||
return updateImplementedFeaturesSection(specContent, updatedFeatures, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a feature from the implemented_features section by name
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param featureName - The name of the feature to remove
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the feature removed
|
||||
*/
|
||||
export function removeImplementedFeature(
|
||||
specContent: string,
|
||||
featureName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Extract existing features
|
||||
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||
|
||||
// Filter out the feature to remove
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing feature in the implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param featureName - The name of the feature to update
|
||||
* @param updates - Partial updates to apply to the feature
|
||||
* @param options - Optional extraction options
|
||||
* @returns Updated XML content with the feature modified
|
||||
*/
|
||||
export function updateImplementedFeature(
|
||||
specContent: string,
|
||||
featureName: string,
|
||||
updates: Partial<ImplementedFeature>,
|
||||
options: ExtractXmlOptions = {}
|
||||
): string {
|
||||
const log = options.logger || logger;
|
||||
|
||||
// Extract existing features
|
||||
const existingFeatures = extractImplementedFeatures(specContent, options);
|
||||
|
||||
// Find and update the feature
|
||||
let found = false;
|
||||
const updatedFeatures = existingFeatures.map((f) => {
|
||||
if (f.name.toLowerCase() === featureName.toLowerCase()) {
|
||||
found = true;
|
||||
return {
|
||||
...f,
|
||||
...updates,
|
||||
// Preserve the original name if not explicitly updated
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature exists in the implemented_features section
|
||||
*
|
||||
* @param specContent - The full XML content
|
||||
* @param featureName - The name of the feature to check
|
||||
* @param options - Optional extraction options
|
||||
* @returns True if the feature exists
|
||||
*/
|
||||
export function hasImplementedFeature(
|
||||
specContent: string,
|
||||
featureName: string,
|
||||
options: ExtractXmlOptions = {}
|
||||
): boolean {
|
||||
const features = extractImplementedFeatures(specContent, options);
|
||||
return features.some((f) => f.name.toLowerCase() === featureName.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert extracted features to SpecOutput.implemented_features format
|
||||
*
|
||||
* @param features - Array of extracted features
|
||||
* @returns Features in SpecOutput format
|
||||
*/
|
||||
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 }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SpecOutput.implemented_features to ImplementedFeature format
|
||||
*
|
||||
* @param specFeatures - Features from SpecOutput
|
||||
* @returns Features in ImplementedFeature format
|
||||
*/
|
||||
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 }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
@@ -23,6 +23,19 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is provided
|
||||
if (feature.title && feature.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${feature.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const created = await featureLoader.create(projectPath, feature);
|
||||
res.json({ success: true, feature: created });
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import type { Feature, FeatureStatus } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('features/update');
|
||||
|
||||
// Statuses that should trigger syncing to app_spec.txt
|
||||
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
|
||||
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -34,6 +40,28 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate title if title is being updated
|
||||
if (updates.title && updates.title.trim()) {
|
||||
const duplicate = await featureLoader.findDuplicateTitle(
|
||||
projectPath,
|
||||
updates.title,
|
||||
featureId // Exclude the current feature from duplicate check
|
||||
);
|
||||
if (duplicate) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `A feature with title "${updates.title}" already exists`,
|
||||
duplicateFeatureId: duplicate.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current feature to detect status changes
|
||||
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||
const newStatus = updates.status as FeatureStatus | undefined;
|
||||
|
||||
const updated = await featureLoader.update(
|
||||
projectPath,
|
||||
featureId,
|
||||
@@ -42,6 +70,22 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
enhancementMode,
|
||||
preEnhancementDescription
|
||||
);
|
||||
|
||||
// Trigger sync to app_spec.txt when status changes to verified or completed
|
||||
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
|
||||
try {
|
||||
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
|
||||
if (synced) {
|
||||
logger.info(
|
||||
`Synced feature "${updated.title || updated.id}" to app_spec.txt on status change to ${newStatus}`
|
||||
);
|
||||
}
|
||||
} catch (syncError) {
|
||||
// Log the sync error but don't fail the update operation
|
||||
logger.error(`Failed to sync feature to app_spec.txt:`, syncError);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, feature: updated });
|
||||
} catch (error) {
|
||||
logError(error, 'Update feature failed');
|
||||
|
||||
@@ -2101,6 +2101,16 @@ Format your response as a structured markdown document.`;
|
||||
feature.justFinishedAt = undefined;
|
||||
}
|
||||
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
|
||||
|
||||
// Sync completed/verified features to app_spec.txt
|
||||
if (status === 'verified' || status === 'completed') {
|
||||
try {
|
||||
await this.featureLoader.syncFeatureToAppSpec(projectPath, feature);
|
||||
} catch (syncError) {
|
||||
// Log but don't fail the status update if sync fails
|
||||
logger.warn(`Failed to sync feature ${featureId} to app_spec.txt:`, syncError);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Feature file may not exist
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
getFeaturesDir,
|
||||
getFeatureDir,
|
||||
getFeatureImagesDir,
|
||||
getAppSpecPath,
|
||||
ensureAutomakerDir,
|
||||
} from '@automaker/platform';
|
||||
import { addImplementedFeature, type ImplementedFeature } from '../lib/xml-extractor.js';
|
||||
|
||||
const logger = createLogger('FeatureLoader');
|
||||
|
||||
@@ -236,6 +238,69 @@ export class FeatureLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a title for comparison (case-insensitive, trimmed)
|
||||
*/
|
||||
private normalizeTitle(title: string): string {
|
||||
return title.toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a feature by its title (case-insensitive match)
|
||||
* @param projectPath - Path to the project
|
||||
* @param title - Title to search for
|
||||
* @returns The matching feature or null if not found
|
||||
*/
|
||||
async findByTitle(projectPath: string, title: string): Promise<Feature | null> {
|
||||
if (!title || !title.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTitle = this.normalizeTitle(title);
|
||||
const features = await this.getAll(projectPath);
|
||||
|
||||
for (const feature of features) {
|
||||
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
|
||||
return feature;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a title already exists on another feature (for duplicate detection)
|
||||
* @param projectPath - Path to the project
|
||||
* @param title - Title to check
|
||||
* @param excludeFeatureId - Optional feature ID to exclude from the check (for updates)
|
||||
* @returns The duplicate feature if found, null otherwise
|
||||
*/
|
||||
async findDuplicateTitle(
|
||||
projectPath: string,
|
||||
title: string,
|
||||
excludeFeatureId?: string
|
||||
): Promise<Feature | null> {
|
||||
if (!title || !title.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedTitle = this.normalizeTitle(title);
|
||||
const features = await this.getAll(projectPath);
|
||||
|
||||
for (const feature of features) {
|
||||
// Skip the feature being updated (if provided)
|
||||
if (excludeFeatureId && feature.id === excludeFeatureId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (feature.title && this.normalizeTitle(feature.title) === normalizedTitle) {
|
||||
return feature;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single feature by ID
|
||||
*/
|
||||
@@ -460,4 +525,64 @@ export class FeatureLoader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a completed feature to the app_spec.txt implemented_features section
|
||||
*
|
||||
* When a feature is completed, this method adds it to the implemented_features
|
||||
* section of the project's app_spec.txt file. This keeps the spec in sync
|
||||
* with the actual state of the codebase.
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param feature - The feature to sync (must have title or description)
|
||||
* @param fileLocations - Optional array of file paths where the feature was implemented
|
||||
* @returns True if the spec was updated, false if no spec exists or feature was skipped
|
||||
*/
|
||||
async syncFeatureToAppSpec(
|
||||
projectPath: string,
|
||||
feature: Feature,
|
||||
fileLocations?: string[]
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const appSpecPath = getAppSpecPath(projectPath);
|
||||
|
||||
// Read the current app_spec.txt
|
||||
let specContent: string;
|
||||
try {
|
||||
specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info(`No app_spec.txt found for project, skipping sync for feature ${feature.id}`);
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Build the implemented feature entry
|
||||
const featureName = feature.title || `Feature: ${feature.id}`;
|
||||
const implementedFeature: ImplementedFeature = {
|
||||
name: featureName,
|
||||
description: feature.description,
|
||||
...(fileLocations && fileLocations.length > 0 ? { file_locations: fileLocations } : {}),
|
||||
};
|
||||
|
||||
// Add the feature to the implemented_features section
|
||||
const updatedSpecContent = addImplementedFeature(specContent, implementedFeature);
|
||||
|
||||
// Check if the content actually changed (feature might already exist)
|
||||
if (updatedSpecContent === specContent) {
|
||||
logger.info(`Feature "${featureName}" already exists in app_spec.txt, skipping`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write the updated spec back to the file
|
||||
await secureFs.writeFile(appSpecPath, updatedSpecContent, 'utf-8');
|
||||
|
||||
logger.info(`Synced feature "${featureName}" to app_spec.txt`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to sync feature ${feature.id} to app_spec.txt:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1027
apps/server/tests/unit/lib/xml-extractor.test.ts
Normal file
1027
apps/server/tests/unit/lib/xml-extractor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -442,4 +442,471 @@ describe('feature-loader.ts', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByTitle', () => {
|
||||
it('should find feature by exact title match (case-insensitive)', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'Login Feature',
|
||||
category: 'auth',
|
||||
description: 'Login implementation',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Logout Feature',
|
||||
category: 'auth',
|
||||
description: 'Logout implementation',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findByTitle(testProjectPath, 'LOGIN FEATURE');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
expect(result?.title).toBe('Login Feature');
|
||||
});
|
||||
|
||||
it('should return null when title is not found', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'Login Feature',
|
||||
category: 'auth',
|
||||
description: 'Login implementation',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findByTitle(testProjectPath, 'Nonexistent Feature');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty or whitespace title', async () => {
|
||||
const result1 = await loader.findByTitle(testProjectPath, '');
|
||||
const result2 = await loader.findByTitle(testProjectPath, ' ');
|
||||
|
||||
expect(result1).toBeNull();
|
||||
expect(result2).toBeNull();
|
||||
});
|
||||
|
||||
it('should skip features without titles', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
// no title
|
||||
category: 'auth',
|
||||
description: 'Login implementation',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Login Feature',
|
||||
category: 'auth',
|
||||
description: 'Another login',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findByTitle(testProjectPath, 'Login Feature');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-2000-def');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDuplicateTitle', () => {
|
||||
it('should find duplicate title', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature description',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findDuplicateTitle(testProjectPath, 'my feature');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
});
|
||||
|
||||
it('should exclude specified feature ID from duplicate check', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 1',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Other Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 2',
|
||||
})
|
||||
);
|
||||
|
||||
// Should not find duplicate when excluding the feature that has the title
|
||||
const result = await loader.findDuplicateTitle(
|
||||
testProjectPath,
|
||||
'My Feature',
|
||||
'feature-1000-abc'
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find duplicate when title exists on different feature', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
{ name: 'feature-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 1',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-2000-def',
|
||||
title: 'Other Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature 2',
|
||||
})
|
||||
);
|
||||
|
||||
// Should find duplicate because feature-1000-abc has the title and we're excluding feature-2000-def
|
||||
const result = await loader.findDuplicateTitle(
|
||||
testProjectPath,
|
||||
'My Feature',
|
||||
'feature-2000-def'
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
});
|
||||
|
||||
it('should return null for empty or whitespace title', async () => {
|
||||
const result1 = await loader.findDuplicateTitle(testProjectPath, '');
|
||||
const result2 = await loader.findDuplicateTitle(testProjectPath, ' ');
|
||||
|
||||
expect(result1).toBeNull();
|
||||
expect(result2).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle titles with leading/trailing whitespace', async () => {
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readdir).mockResolvedValue([
|
||||
{ name: 'feature-1', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
id: 'feature-1000-abc',
|
||||
title: 'My Feature',
|
||||
category: 'ui',
|
||||
description: 'Feature description',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await loader.findDuplicateTitle(testProjectPath, ' My Feature ');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe('feature-1000-abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFeatureToAppSpec', () => {
|
||||
const sampleAppSpec = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project_specification>
|
||||
<project_name>Test Project</project_name>
|
||||
<core_capabilities>
|
||||
<capability>Testing</capability>
|
||||
</core_capabilities>
|
||||
<implemented_features>
|
||||
<feature>
|
||||
<name>Existing Feature</name>
|
||||
<description>Already implemented</description>
|
||||
</feature>
|
||||
</implemented_features>
|
||||
</project_specification>`;
|
||||
|
||||
const appSpecWithoutFeatures = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project_specification>
|
||||
<project_name>Test Project</project_name>
|
||||
<core_capabilities>
|
||||
<capability>Testing</capability>
|
||||
</core_capabilities>
|
||||
</project_specification>`;
|
||||
|
||||
it('should add feature to app_spec.txt', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature description',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('app_spec.txt'),
|
||||
expect.stringContaining('New Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('A new feature description'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add feature with file locations', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'Feature With Locations',
|
||||
category: 'backend',
|
||||
description: 'Feature with file locations',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature, [
|
||||
'src/feature.ts',
|
||||
'src/utils/helper.ts',
|
||||
]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('src/feature.ts'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('src/utils/helper.ts'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when app_spec.txt does not exist', async () => {
|
||||
const error: any = new Error('File not found');
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature description',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false when feature already exists (duplicate)', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-5678-xyz',
|
||||
title: 'Existing Feature', // Same name as existing feature
|
||||
category: 'ui',
|
||||
description: 'Different description',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use feature ID as fallback name when title is missing', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
category: 'ui',
|
||||
description: 'Feature without title',
|
||||
// No title property
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('Feature: feature-1234-abc'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle app_spec without implemented_features section', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(appSpecWithoutFeatures);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'First Feature',
|
||||
category: 'ui',
|
||||
description: 'First implemented feature',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('<implemented_features>'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('First Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on non-ENOENT file read errors', async () => {
|
||||
const error = new Error('Permission denied');
|
||||
vi.mocked(fs.readFile).mockRejectedValueOnce(error);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature description',
|
||||
};
|
||||
|
||||
await expect(loader.syncFeatureToAppSpec(testProjectPath, feature)).rejects.toThrow(
|
||||
'Permission denied'
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing features when adding a new one', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'New Feature',
|
||||
category: 'ui',
|
||||
description: 'A new feature',
|
||||
};
|
||||
|
||||
await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
// Verify both old and new features are in the output
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('Existing Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('New Feature'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape special characters in feature name and description', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'Feature with <special> & "chars"',
|
||||
category: 'ui',
|
||||
description: 'Description with <tags> & "quotes"',
|
||||
};
|
||||
|
||||
const result = await loader.syncFeatureToAppSpec(testProjectPath, feature);
|
||||
|
||||
expect(result).toBe(true);
|
||||
// The XML should have escaped characters
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('<special>'),
|
||||
'utf-8'
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.stringContaining('&'),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add empty file_locations array', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(sampleAppSpec);
|
||||
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const feature = {
|
||||
id: 'feature-1234-abc',
|
||||
title: 'Feature Without Locations',
|
||||
category: 'ui',
|
||||
description: 'No file locations',
|
||||
};
|
||||
|
||||
await loader.syncFeatureToAppSpec(testProjectPath, feature, []);
|
||||
|
||||
// File locations should not be included when array is empty
|
||||
const writeCall = vi.mocked(fs.writeFile).mock.calls[0];
|
||||
const writtenContent = writeCall[1] as string;
|
||||
|
||||
// Count occurrences of file_locations - should only have the one from Existing Feature if any
|
||||
// The new feature should not add file_locations
|
||||
expect(writtenContent).toContain('Feature Without Locations');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user