diff --git a/apps/server/src/lib/app-spec-format.ts b/apps/server/src/lib/app-spec-format.ts
index a52bf1f7..f8393cf1 100644
--- a/apps/server/src/lib/app-spec-format.ts
+++ b/apps/server/src/lib/app-spec-format.ts
@@ -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 '';
}
diff --git a/apps/server/src/lib/xml-extractor.ts b/apps/server/src/lib/xml-extractor.ts
new file mode 100644
index 00000000..26e51bc3
--- /dev/null
+++ b/apps/server/src/lib/xml-extractor.ts
@@ -0,0 +1,466 @@
+/**
+ * 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, ''');
+}
+
+/**
+ * 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}>(.*?)<\\/${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 ... 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 = /([\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>/);
+ const name = nameMatch ? unescapeXml(nameMatch[1].trim()) : '';
+
+ // Extract description
+ const descMatch = featureContent.match(/(.*?)<\/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 i1 = indent;
+ const i2 = indent + indent;
+ const i3 = indent + indent + indent;
+ const i4 = indent + indent + indent + indent;
+
+ let xml = `${i2}
+${i3}${escapeXml(feature.name)}
+${i3}${escapeXml(feature.description)}`;
+
+ if (feature.file_locations && feature.file_locations.length > 0) {
+ xml += `
+${i3}
+${feature.file_locations.map((loc) => `${i4}${escapeXml(loc)}`).join('\n')}
+${i3}`;
+ }
+
+ xml += `
+${i2}`;
+
+ 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 = `
+${newSectionContent}
+${indent}`;
+
+ // Check if section exists
+ const sectionRegex = /[\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 = '';
+ 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
+ const projectSpecEnd = '';
+ const fallbackIndex = specContent.indexOf(projectSpecEnd);
+
+ if (fallbackIndex !== -1) {
+ log.debug('Inserting implemented_features before ');
+ 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,
+ 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 }
+ : {}),
+ }));
+}
diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts
index 5f04ecdb..81134a3e 100644
--- a/apps/server/src/routes/features/routes/create.ts
+++ b/apps/server/src/routes/features/routes/create.ts
@@ -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) {
diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts
index 1a89cda3..a5b532c1 100644
--- a/apps/server/src/routes/features/routes/update.ts
+++ b/apps/server/src/routes/features/routes/update.ts
@@ -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 => {
@@ -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');
diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts
index 05722181..e8bb6875 100644
--- a/apps/server/src/services/auto-mode-service.ts
+++ b/apps/server/src/services/auto-mode-service.ts
@@ -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
}
diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts
index 409abd2a..6ae67c6c 100644
--- a/apps/server/src/services/feature-loader.ts
+++ b/apps/server/src/services/feature-loader.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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;
+ }
+ }
}
diff --git a/apps/server/tests/unit/lib/xml-extractor.test.ts b/apps/server/tests/unit/lib/xml-extractor.test.ts
new file mode 100644
index 00000000..00829990
--- /dev/null
+++ b/apps/server/tests/unit/lib/xml-extractor.test.ts
@@ -0,0 +1,1026 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import {
+ escapeXml,
+ unescapeXml,
+ extractXmlSection,
+ extractXmlElements,
+ extractImplementedFeatures,
+ extractImplementedFeatureNames,
+ featureToXml,
+ featuresToXml,
+ updateImplementedFeaturesSection,
+ addImplementedFeature,
+ removeImplementedFeature,
+ updateImplementedFeature,
+ hasImplementedFeature,
+ toSpecOutputFeatures,
+ fromSpecOutputFeatures,
+ type ImplementedFeature,
+ type XmlExtractorLogger,
+} from '@/lib/xml-extractor.js';
+
+describe('xml-extractor.ts', () => {
+ // Mock logger for testing custom logger functionality
+ const createMockLogger = (): XmlExtractorLogger & { calls: string[] } => {
+ const calls: string[] = [];
+ return {
+ calls,
+ debug: vi.fn((msg: string) => calls.push(`debug: ${msg}`)),
+ warn: vi.fn((msg: string) => calls.push(`warn: ${msg}`)),
+ };
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('escapeXml', () => {
+ it('should escape ampersand', () => {
+ expect(escapeXml('foo & bar')).toBe('foo & bar');
+ });
+
+ it('should escape less than', () => {
+ expect(escapeXml('a < b')).toBe('a < b');
+ });
+
+ it('should escape greater than', () => {
+ expect(escapeXml('a > b')).toBe('a > b');
+ });
+
+ it('should escape double quotes', () => {
+ expect(escapeXml('say "hello"')).toBe('say "hello"');
+ });
+
+ it('should escape single quotes', () => {
+ expect(escapeXml("it's" + ' fine')).toBe('it's fine');
+ });
+
+ it('should handle null', () => {
+ expect(escapeXml(null)).toBe('');
+ });
+
+ it('should handle undefined', () => {
+ expect(escapeXml(undefined)).toBe('');
+ });
+
+ it('should handle empty string', () => {
+ expect(escapeXml('')).toBe('');
+ });
+
+ it('should escape multiple special characters', () => {
+ expect(escapeXml('a < b & c > d "e" \'f\'')).toBe(
+ 'a < b & c > d "e" 'f''
+ );
+ });
+ });
+
+ describe('unescapeXml', () => {
+ it('should unescape ampersand', () => {
+ expect(unescapeXml('foo & bar')).toBe('foo & bar');
+ });
+
+ it('should unescape less than', () => {
+ expect(unescapeXml('a < b')).toBe('a < b');
+ });
+
+ it('should unescape greater than', () => {
+ expect(unescapeXml('a > b')).toBe('a > b');
+ });
+
+ it('should unescape double quotes', () => {
+ expect(unescapeXml('say "hello"')).toBe('say "hello"');
+ });
+
+ it('should unescape single quotes', () => {
+ expect(unescapeXml('it's fine')).toBe("it's fine");
+ });
+
+ it('should handle empty string', () => {
+ expect(unescapeXml('')).toBe('');
+ });
+
+ it('should roundtrip with escapeXml', () => {
+ const original = 'Test & "quoted" \'apostrophe\'';
+ expect(unescapeXml(escapeXml(original))).toBe(original);
+ });
+ });
+
+ describe('extractXmlSection', () => {
+ it('should extract section content', () => {
+ const xml = '';
+ expect(extractXmlSection(xml, 'section')).toBe('content here');
+ });
+
+ it('should extract multiline section content', () => {
+ const xml = `
+
+`;
+ expect(extractXmlSection(xml, 'section')).toContain('line 1');
+ expect(extractXmlSection(xml, 'section')).toContain('line 2');
+ });
+
+ it('should return null for non-existent section', () => {
+ const xml = 'content';
+ expect(extractXmlSection(xml, 'section')).toBeNull();
+ });
+
+ it('should be case-insensitive', () => {
+ const xml = '';
+ expect(extractXmlSection(xml, 'section')).toBe('content');
+ });
+
+ it('should handle empty section', () => {
+ const xml = '';
+ expect(extractXmlSection(xml, 'section')).toBe('');
+ });
+ });
+
+ describe('extractXmlElements', () => {
+ it('should extract all element values', () => {
+ const xml = '- one
- two
- three
';
+ expect(extractXmlElements(xml, 'item')).toEqual(['one', 'two', 'three']);
+ });
+
+ it('should return empty array for non-existent elements', () => {
+ const xml = 'value';
+ expect(extractXmlElements(xml, 'item')).toEqual([]);
+ });
+
+ it('should trim whitespace', () => {
+ const xml = '- spaced
';
+ expect(extractXmlElements(xml, 'item')).toEqual(['spaced']);
+ });
+
+ it('should unescape XML entities', () => {
+ const xml = '- foo & bar
';
+ expect(extractXmlElements(xml, 'item')).toEqual(['foo & bar']);
+ });
+
+ it('should handle empty elements', () => {
+ const xml = '- value
';
+ expect(extractXmlElements(xml, 'item')).toEqual(['', 'value']);
+ });
+ });
+
+ describe('extractImplementedFeatures', () => {
+ const sampleSpec = `
+
+ Test Project
+
+
+ Feature One
+ First feature description
+
+
+ Feature Two
+ Second feature description
+
+ src/feature-two.ts
+ src/utils/helper.ts
+
+
+
+`;
+
+ it('should extract all features', () => {
+ const features = extractImplementedFeatures(sampleSpec);
+ expect(features).toHaveLength(2);
+ });
+
+ it('should extract feature names', () => {
+ const features = extractImplementedFeatures(sampleSpec);
+ expect(features[0].name).toBe('Feature One');
+ expect(features[1].name).toBe('Feature Two');
+ });
+
+ it('should extract feature descriptions', () => {
+ const features = extractImplementedFeatures(sampleSpec);
+ expect(features[0].description).toBe('First feature description');
+ expect(features[1].description).toBe('Second feature description');
+ });
+
+ it('should extract file_locations when present', () => {
+ const features = extractImplementedFeatures(sampleSpec);
+ expect(features[0].file_locations).toBeUndefined();
+ expect(features[1].file_locations).toEqual(['src/feature-two.ts', 'src/utils/helper.ts']);
+ });
+
+ it('should return empty array for missing section', () => {
+ const xml =
+ 'Test';
+ expect(extractImplementedFeatures(xml)).toEqual([]);
+ });
+
+ it('should return empty array for empty section', () => {
+ const xml = `
+
+
+ `;
+ expect(extractImplementedFeatures(xml)).toEqual([]);
+ });
+
+ it('should handle escaped content', () => {
+ const xml = `
+
+ Test & Feature
+ Uses <brackets>
+
+ `;
+ const features = extractImplementedFeatures(xml);
+ expect(features[0].name).toBe('Test & Feature');
+ expect(features[0].description).toBe('Uses ');
+ });
+ });
+
+ describe('extractImplementedFeatureNames', () => {
+ it('should return only feature names', () => {
+ const xml = `
+
+ Feature A
+ Description A
+
+
+ Feature B
+ Description B
+
+ `;
+ expect(extractImplementedFeatureNames(xml)).toEqual(['Feature A', 'Feature B']);
+ });
+
+ it('should return empty array for no features', () => {
+ const xml = '';
+ expect(extractImplementedFeatureNames(xml)).toEqual([]);
+ });
+ });
+
+ describe('featureToXml', () => {
+ it('should generate XML for feature without file_locations', () => {
+ const feature: ImplementedFeature = {
+ name: 'My Feature',
+ description: 'Feature description',
+ };
+ const xml = featureToXml(feature);
+ expect(xml).toContain('My Feature');
+ expect(xml).toContain('Feature description');
+ expect(xml).not.toContain('');
+ });
+
+ it('should generate XML for feature with file_locations', () => {
+ const feature: ImplementedFeature = {
+ name: 'My Feature',
+ description: 'Feature description',
+ file_locations: ['src/index.ts', 'src/utils.ts'],
+ };
+ const xml = featureToXml(feature);
+ expect(xml).toContain('');
+ expect(xml).toContain('src/index.ts');
+ expect(xml).toContain('src/utils.ts');
+ });
+
+ it('should escape special characters', () => {
+ const feature: ImplementedFeature = {
+ name: 'Test & Feature',
+ description: 'Has ',
+ };
+ const xml = featureToXml(feature);
+ expect(xml).toContain('Test & Feature');
+ expect(xml).toContain('Has <tags>');
+ });
+
+ it('should not include empty file_locations array', () => {
+ const feature: ImplementedFeature = {
+ name: 'Feature',
+ description: 'Desc',
+ file_locations: [],
+ };
+ const xml = featureToXml(feature);
+ expect(xml).not.toContain('');
+ });
+ });
+
+ describe('featuresToXml', () => {
+ it('should generate XML for multiple features', () => {
+ const features: ImplementedFeature[] = [
+ { name: 'Feature 1', description: 'Desc 1' },
+ { name: 'Feature 2', description: 'Desc 2' },
+ ];
+ const xml = featuresToXml(features);
+ expect(xml).toContain('Feature 1');
+ expect(xml).toContain('Feature 2');
+ });
+
+ it('should handle empty array', () => {
+ expect(featuresToXml([])).toBe('');
+ });
+ });
+
+ describe('updateImplementedFeaturesSection', () => {
+ const baseSpec = `
+
+ Test
+
+ Testing
+
+
+
+ Old Feature
+ Old description
+
+
+`;
+
+ it('should replace existing section', () => {
+ const newFeatures: ImplementedFeature[] = [
+ { name: 'New Feature', description: 'New description' },
+ ];
+ const result = updateImplementedFeaturesSection(baseSpec, newFeatures);
+ expect(result).toContain('New Feature');
+ expect(result).not.toContain('Old Feature');
+ });
+
+ it('should insert section after core_capabilities if missing', () => {
+ const specWithoutSection = `
+
+ Test
+
+ Testing
+
+`;
+ const newFeatures: ImplementedFeature[] = [
+ { name: 'New Feature', description: 'New description' },
+ ];
+ const result = updateImplementedFeaturesSection(specWithoutSection, newFeatures);
+ expect(result).toContain('');
+ expect(result).toContain('New Feature');
+ });
+
+ it('should handle multiple features', () => {
+ const newFeatures: ImplementedFeature[] = [
+ { name: 'Feature A', description: 'Desc A' },
+ { name: 'Feature B', description: 'Desc B', file_locations: ['src/b.ts'] },
+ ];
+ const result = updateImplementedFeaturesSection(baseSpec, newFeatures);
+ expect(result).toContain('Feature A');
+ expect(result).toContain('Feature B');
+ expect(result).toContain('src/b.ts');
+ });
+ });
+
+ describe('addImplementedFeature', () => {
+ const baseSpec = `
+
+ Existing Feature
+ Existing description
+
+ `;
+
+ it('should add new feature', () => {
+ const newFeature: ImplementedFeature = {
+ name: 'New Feature',
+ description: 'New description',
+ };
+ const result = addImplementedFeature(baseSpec, newFeature);
+ expect(result).toContain('Existing Feature');
+ expect(result).toContain('New Feature');
+ });
+
+ it('should not add duplicate feature', () => {
+ const duplicate: ImplementedFeature = {
+ name: 'Existing Feature',
+ description: 'Different description',
+ };
+ const result = addImplementedFeature(baseSpec, duplicate);
+ // Should still have only one instance
+ const matches = result.match(/Existing Feature/g);
+ expect(matches).toHaveLength(1);
+ });
+
+ it('should be case-insensitive for duplicates', () => {
+ const duplicate: ImplementedFeature = {
+ name: 'EXISTING FEATURE',
+ description: 'Different description',
+ };
+ const result = addImplementedFeature(baseSpec, duplicate);
+ expect(result).not.toContain('EXISTING FEATURE');
+ });
+ });
+
+ describe('removeImplementedFeature', () => {
+ const baseSpec = `
+
+ Feature A
+ Description A
+
+
+ Feature B
+ Description B
+
+ `;
+
+ it('should remove feature by name', () => {
+ const result = removeImplementedFeature(baseSpec, 'Feature A');
+ expect(result).not.toContain('Feature A');
+ expect(result).toContain('Feature B');
+ });
+
+ it('should be case-insensitive', () => {
+ const result = removeImplementedFeature(baseSpec, 'feature a');
+ expect(result).not.toContain('Feature A');
+ expect(result).toContain('Feature B');
+ });
+
+ it('should return unchanged content if feature not found', () => {
+ const result = removeImplementedFeature(baseSpec, 'Nonexistent');
+ expect(result).toContain('Feature A');
+ expect(result).toContain('Feature B');
+ });
+ });
+
+ describe('updateImplementedFeature', () => {
+ const baseSpec = `
+
+ My Feature
+ Original description
+
+ `;
+
+ it('should update feature description', () => {
+ const result = updateImplementedFeature(baseSpec, 'My Feature', {
+ description: 'Updated description',
+ });
+ expect(result).toContain('Updated description');
+ expect(result).not.toContain('Original description');
+ });
+
+ it('should add file_locations', () => {
+ const result = updateImplementedFeature(baseSpec, 'My Feature', {
+ file_locations: ['src/new.ts'],
+ });
+ expect(result).toContain('');
+ expect(result).toContain('src/new.ts');
+ });
+
+ it('should preserve feature name if not updated', () => {
+ const result = updateImplementedFeature(baseSpec, 'My Feature', {
+ description: 'New desc',
+ });
+ expect(result).toContain('My Feature');
+ });
+
+ it('should be case-insensitive', () => {
+ const result = updateImplementedFeature(baseSpec, 'my feature', {
+ description: 'Updated',
+ });
+ expect(result).toContain('Updated');
+ });
+
+ it('should return unchanged content if feature not found', () => {
+ const result = updateImplementedFeature(baseSpec, 'Nonexistent', {
+ description: 'New',
+ });
+ expect(result).toContain('Original description');
+ });
+ });
+
+ describe('hasImplementedFeature', () => {
+ const baseSpec = `
+
+ Existing Feature
+ Description
+
+ `;
+
+ it('should return true for existing feature', () => {
+ expect(hasImplementedFeature(baseSpec, 'Existing Feature')).toBe(true);
+ });
+
+ it('should return false for non-existing feature', () => {
+ expect(hasImplementedFeature(baseSpec, 'Nonexistent')).toBe(false);
+ });
+
+ it('should be case-insensitive', () => {
+ expect(hasImplementedFeature(baseSpec, 'existing feature')).toBe(true);
+ expect(hasImplementedFeature(baseSpec, 'EXISTING FEATURE')).toBe(true);
+ });
+ });
+
+ describe('toSpecOutputFeatures', () => {
+ it('should convert to SpecOutput format', () => {
+ const features: ImplementedFeature[] = [
+ { name: 'Feature 1', description: 'Desc 1' },
+ { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] },
+ ];
+ const result = toSpecOutputFeatures(features);
+ expect(result).toEqual([
+ { name: 'Feature 1', description: 'Desc 1' },
+ { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] },
+ ]);
+ });
+
+ it('should handle empty array', () => {
+ expect(toSpecOutputFeatures([])).toEqual([]);
+ });
+ });
+
+ describe('fromSpecOutputFeatures', () => {
+ it('should convert from SpecOutput format', () => {
+ const specFeatures = [
+ { name: 'Feature 1', description: 'Desc 1' },
+ { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] },
+ ];
+ const result = fromSpecOutputFeatures(specFeatures);
+ expect(result).toEqual([
+ { name: 'Feature 1', description: 'Desc 1' },
+ { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f2.ts'] },
+ ]);
+ });
+
+ it('should handle empty array', () => {
+ expect(fromSpecOutputFeatures([])).toEqual([]);
+ });
+ });
+
+ describe('roundtrip', () => {
+ it('should maintain data integrity through extract -> update cycle', () => {
+ const originalSpec = `
+
+ Test
+
+ Testing
+
+
+
+ Test & Feature
+ Uses <special> chars
+
+ src/test.ts
+
+
+
+`;
+
+ // Extract features
+ const features = extractImplementedFeatures(originalSpec);
+ expect(features[0].name).toBe('Test & Feature');
+ expect(features[0].description).toBe('Uses chars');
+
+ // Update with same features
+ const result = updateImplementedFeaturesSection(originalSpec, features);
+
+ // Re-extract and verify
+ const reExtracted = extractImplementedFeatures(result);
+ expect(reExtracted[0].name).toBe('Test & Feature');
+ expect(reExtracted[0].description).toBe('Uses chars');
+ expect(reExtracted[0].file_locations).toEqual(['src/test.ts']);
+ });
+ });
+
+ describe('custom logger', () => {
+ it('should use custom logger for extractXmlSection', () => {
+ const mockLogger = createMockLogger();
+ const xml = '';
+ extractXmlSection(xml, 'section', { logger: mockLogger });
+ expect(mockLogger.debug).toHaveBeenCalledWith('Extracted section');
+ });
+
+ it('should log when section is not found', () => {
+ const mockLogger = createMockLogger();
+ const xml = 'content';
+ extractXmlSection(xml, 'missing', { logger: mockLogger });
+ expect(mockLogger.debug).toHaveBeenCalledWith('Section not found');
+ });
+
+ it('should use custom logger for extractXmlElements', () => {
+ const mockLogger = createMockLogger();
+ const xml = '- one
- two
';
+ extractXmlElements(xml, 'item', { logger: mockLogger });
+ expect(mockLogger.debug).toHaveBeenCalledWith('Extracted 2 - elements');
+ });
+
+ it('should use custom logger for extractImplementedFeatures', () => {
+ const mockLogger = createMockLogger();
+ const xml = `
+
+ Test
+ Desc
+
+ `;
+ extractImplementedFeatures(xml, { logger: mockLogger });
+ expect(mockLogger.debug).toHaveBeenCalledWith('Extracted 1 implemented features');
+ });
+
+ it('should log when no implemented_features section found', () => {
+ const mockLogger = createMockLogger();
+ const xml = 'content';
+ extractImplementedFeatures(xml, { logger: mockLogger });
+ expect(mockLogger.debug).toHaveBeenCalledWith('No implemented_features section found');
+ });
+
+ it('should use custom logger warn for missing insertion point', () => {
+ const mockLogger = createMockLogger();
+ // XML without project_specification, core_capabilities, or implemented_features
+ const xml = 'content';
+ const features: ImplementedFeature[] = [{ name: 'Test', description: 'Desc' }];
+ updateImplementedFeaturesSection(xml, features, { logger: mockLogger });
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ 'Could not find appropriate insertion point for implemented_features'
+ );
+ });
+ });
+
+ describe('edge cases', () => {
+ describe('escapeXml edge cases', () => {
+ it('should handle strings with only special characters', () => {
+ expect(escapeXml('<>&"\'')).toBe('<>&"'');
+ });
+
+ it('should handle very long strings', () => {
+ const longString = 'a'.repeat(10000) + '&' + 'b'.repeat(10000);
+ const escaped = escapeXml(longString);
+ expect(escaped).toContain('&');
+ expect(escaped.length).toBe(20005); // +4 for & minus &
+ });
+
+ it('should handle unicode characters without escaping', () => {
+ const unicode = '日本語 emoji: 🚀 symbols: ∞ ≠ ≤';
+ expect(escapeXml(unicode)).toBe(unicode);
+ });
+ });
+
+ describe('unescapeXml edge cases', () => {
+ it('should handle strings with only entities', () => {
+ expect(unescapeXml('<>&"'')).toBe('<>&"\'');
+ });
+
+ it('should not double-unescape', () => {
+ // < should become < (not <)
+ expect(unescapeXml('<')).toBe('<');
+ });
+
+ it('should handle partial/invalid entities gracefully', () => {
+ // Invalid entities should pass through unchanged
+ expect(unescapeXml('&unknown;')).toBe('&unknown;');
+ expect(unescapeXml('&')).toBe('&'); // Missing semicolon
+ });
+ });
+
+ describe('extractXmlSection edge cases', () => {
+ it('should handle nested tags with same name', () => {
+ // Note: regex-based parsing with non-greedy matching will match
+ // from first opening tag to first closing tag
+ const xml = 'inner';
+ // Non-greedy [\s\S]*? matches from first to first
+ expect(extractXmlSection(xml, 'outer')).toBe('inner');
+ });
+
+ it('should handle self-closing tags (returns null)', () => {
+ const xml = '';
+ // Regex expects content between tags, self-closing won't match
+ expect(extractXmlSection(xml, 'section')).toBeNull();
+ });
+
+ it('should handle tags with attributes', () => {
+ const xml = '';
+ // The regex matches exact tag names, so this won't match
+ expect(extractXmlSection(xml, 'section')).toBeNull();
+ });
+
+ it('should handle whitespace in tag content', () => {
+ const xml = '';
+ expect(extractXmlSection(xml, 'section')).toBe(' \n\t ');
+ });
+ });
+
+ describe('extractXmlElements edge cases', () => {
+ it('should handle elements across multiple lines', () => {
+ const xml = `
+
-
+ first
+
+ - second
+ `;
+ // Note: multiline content in single element may not be captured due to . not matching newlines
+ const result = extractXmlElements(xml, 'item');
+ expect(result).toHaveLength(1); // Only matches single-line content
+ expect(result[0]).toBe('second');
+ });
+
+ it('should handle consecutive elements without whitespace', () => {
+ const xml = '- a
- b
- c
';
+ expect(extractXmlElements(xml, 'item')).toEqual(['a', 'b', 'c']);
+ });
+ });
+
+ describe('extractImplementedFeatures edge cases', () => {
+ it('should skip features without names', () => {
+ const xml = `
+
+ Orphan description
+
+
+ Valid Feature
+ Has name
+
+ `;
+ const features = extractImplementedFeatures(xml);
+ expect(features).toHaveLength(1);
+ expect(features[0].name).toBe('Valid Feature');
+ });
+
+ it('should handle features with empty names', () => {
+ const xml = `
+
+
+ Empty name
+
+ `;
+ const features = extractImplementedFeatures(xml);
+ expect(features).toHaveLength(0); // Empty name is falsy
+ });
+
+ it('should handle features with whitespace-only names', () => {
+ const xml = `
+
+
+ Whitespace name
+
+ `;
+ const features = extractImplementedFeatures(xml);
+ expect(features).toHaveLength(0); // Trimmed whitespace is empty
+ });
+
+ it('should handle empty file_locations section', () => {
+ const xml = `
+
+ Test
+ Desc
+
+
+
+ `;
+ const features = extractImplementedFeatures(xml);
+ expect(features[0].file_locations).toBeUndefined();
+ });
+ });
+
+ describe('featureToXml edge cases', () => {
+ it('should handle custom indentation', () => {
+ const feature: ImplementedFeature = {
+ name: 'Test',
+ description: 'Desc',
+ };
+ const xml = featureToXml(feature, '\t');
+ expect(xml).toContain('\t\t');
+ expect(xml).toContain('\t\t\tTest');
+ });
+
+ it('should handle empty description', () => {
+ const feature: ImplementedFeature = {
+ name: 'Test',
+ description: '',
+ };
+ const xml = featureToXml(feature);
+ expect(xml).toContain('');
+ });
+
+ it('should handle undefined file_locations', () => {
+ const feature: ImplementedFeature = {
+ name: 'Test',
+ description: 'Desc',
+ file_locations: undefined,
+ };
+ const xml = featureToXml(feature);
+ expect(xml).not.toContain('file_locations');
+ });
+ });
+
+ describe('updateImplementedFeaturesSection edge cases', () => {
+ it('should insert before as fallback', () => {
+ const specWithoutCoreCapabilities = `
+
+ Test
+`;
+ const newFeatures: ImplementedFeature[] = [
+ { name: 'New Feature', description: 'New description' },
+ ];
+ const result = updateImplementedFeaturesSection(specWithoutCoreCapabilities, newFeatures);
+ expect(result).toContain('');
+ expect(result).toContain('New Feature');
+ expect(result.indexOf('')).toBeLessThan(
+ result.indexOf('')
+ );
+ });
+
+ it('should return unchanged content when no insertion point found', () => {
+ const invalidSpec = 'content';
+ const newFeatures: ImplementedFeature[] = [{ name: 'Feature', description: 'Desc' }];
+ const result = updateImplementedFeaturesSection(invalidSpec, newFeatures);
+ expect(result).toBe(invalidSpec);
+ });
+
+ it('should handle empty features array', () => {
+ const spec = `
+
+ Old
+ Old desc
+
+ `;
+ const result = updateImplementedFeaturesSection(spec, []);
+ expect(result).toContain('');
+ expect(result).not.toContain('Old');
+ });
+ });
+
+ describe('addImplementedFeature edge cases', () => {
+ it('should create section when adding to spec without implemented_features', () => {
+ const specWithoutSection = `
+
+ Testing
+
+`;
+ const newFeature: ImplementedFeature = {
+ name: 'First Feature',
+ description: 'First description',
+ };
+ const result = addImplementedFeature(specWithoutSection, newFeature);
+ expect(result).toContain('');
+ expect(result).toContain('First Feature');
+ });
+
+ it('should handle feature with all fields populated', () => {
+ const spec = ``;
+ const newFeature: ImplementedFeature = {
+ name: 'Complete Feature',
+ description: 'Full description',
+ file_locations: ['src/a.ts', 'src/b.ts', 'src/c.ts'],
+ };
+ const result = addImplementedFeature(spec, newFeature);
+ expect(result).toContain('Complete Feature');
+ expect(result).toContain('src/a.ts');
+ expect(result).toContain('src/b.ts');
+ expect(result).toContain('src/c.ts');
+ });
+ });
+
+ describe('updateImplementedFeature edge cases', () => {
+ it('should allow updating feature name', () => {
+ const spec = `
+
+ Old Name
+ Desc
+
+ `;
+ const result = updateImplementedFeature(spec, 'Old Name', {
+ name: 'New Name',
+ });
+ expect(result).toContain('New Name');
+ expect(result).not.toContain('Old Name');
+ });
+
+ it('should allow clearing file_locations', () => {
+ const spec = `
+
+ Test
+ Desc
+
+ src/old.ts
+
+
+ `;
+ const result = updateImplementedFeature(spec, 'Test', {
+ file_locations: [],
+ });
+ expect(result).not.toContain('file_locations');
+ expect(result).not.toContain('src/old.ts');
+ });
+
+ it('should handle updating multiple fields at once', () => {
+ const spec = `
+
+ Original
+ Original desc
+
+ `;
+ const result = updateImplementedFeature(spec, 'Original', {
+ name: 'Updated',
+ description: 'Updated desc',
+ file_locations: ['new/path.ts'],
+ });
+ expect(result).toContain('Updated');
+ expect(result).toContain('Updated desc');
+ expect(result).toContain('new/path.ts');
+ });
+ });
+
+ describe('toSpecOutputFeatures and fromSpecOutputFeatures edge cases', () => {
+ it('should handle features with empty file_locations array', () => {
+ const features: ImplementedFeature[] = [
+ { name: 'Test', description: 'Desc', file_locations: [] },
+ ];
+ const specOutput = toSpecOutputFeatures(features);
+ expect(specOutput[0].file_locations).toBeUndefined();
+ });
+
+ it('should handle round-trip conversion', () => {
+ const original: ImplementedFeature[] = [
+ { name: 'Feature 1', description: 'Desc 1' },
+ { name: 'Feature 2', description: 'Desc 2', file_locations: ['src/f.ts'] },
+ ];
+ const specOutput = toSpecOutputFeatures(original);
+ const restored = fromSpecOutputFeatures(specOutput);
+ expect(restored).toEqual(original);
+ });
+ });
+ });
+
+ describe('integration scenarios', () => {
+ it('should handle a complete spec file workflow', () => {
+ // Start with a minimal spec
+ let spec = `
+
+ My App
+
+ User management
+
+`;
+
+ // Add first feature
+ spec = addImplementedFeature(spec, {
+ name: 'User Authentication',
+ description: 'Login and logout functionality',
+ file_locations: ['src/auth/login.ts', 'src/auth/logout.ts'],
+ });
+ expect(hasImplementedFeature(spec, 'User Authentication')).toBe(true);
+
+ // Add second feature
+ spec = addImplementedFeature(spec, {
+ name: 'User Profile',
+ description: 'View and edit user profile',
+ });
+ expect(extractImplementedFeatureNames(spec)).toEqual(['User Authentication', 'User Profile']);
+
+ // Update first feature
+ spec = updateImplementedFeature(spec, 'User Authentication', {
+ file_locations: ['src/auth/login.ts', 'src/auth/logout.ts', 'src/auth/session.ts'],
+ });
+ const features = extractImplementedFeatures(spec);
+ expect(features[0].file_locations).toContain('src/auth/session.ts');
+
+ // Remove a feature
+ spec = removeImplementedFeature(spec, 'User Profile');
+ expect(hasImplementedFeature(spec, 'User Profile')).toBe(false);
+ expect(hasImplementedFeature(spec, 'User Authentication')).toBe(true);
+ });
+
+ it('should handle special characters throughout workflow', () => {
+ const spec = `
+
+`;
+
+ const result = addImplementedFeature(spec, {
+ name: 'Search & Filter',
+ description: 'Supports syntax with "quoted" terms',
+ file_locations: ["src/search/parser's.ts"],
+ });
+
+ const features = extractImplementedFeatures(result);
+ expect(features[0].name).toBe('Search & Filter');
+ expect(features[0].description).toBe('Supports syntax with "quoted" terms');
+ expect(features[0].file_locations?.[0]).toBe("src/search/parser's.ts");
+ });
+
+ it('should preserve other XML content when modifying features', () => {
+ const spec = `
+
+ Preserved Name
+ This should be preserved
+
+ Capability 1
+ Capability 2
+
+
+
+ Old Feature
+ Will be replaced
+
+
+ Keep this too
+`;
+
+ const result = updateImplementedFeaturesSection(spec, [
+ { name: 'New Feature', description: 'New desc' },
+ ]);
+
+ expect(result).toContain('Preserved Name');
+ expect(result).toContain('This should be preserved');
+ expect(result).toContain('Capability 1');
+ expect(result).toContain('Capability 2');
+ expect(result).toContain('Keep this too');
+ expect(result).not.toContain('Old Feature');
+ expect(result).toContain('New Feature');
+ });
+ });
+});
diff --git a/apps/server/tests/unit/services/feature-loader.test.ts b/apps/server/tests/unit/services/feature-loader.test.ts
index dc540982..d70f0326 100644
--- a/apps/server/tests/unit/services/feature-loader.test.ts
+++ b/apps/server/tests/unit/services/feature-loader.test.ts
@@ -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 = `
+
+ Test Project
+
+ Testing
+
+
+
+ Existing Feature
+ Already implemented
+
+
+`;
+
+ const appSpecWithoutFeatures = `
+
+ Test Project
+
+ Testing
+
+`;
+
+ 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(''),
+ '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 & "chars"',
+ category: 'ui',
+ description: 'Description with & "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');
+ });
+ });
});