feat: add three viewing modes for app specification (#566)

* feat: add three viewing modes for app specification

Introduces View, Edit, and Source modes for the spec page:

- View: Clean read-only display with cards, badges, and accordions
- Edit: Structured form-based editor for all spec fields
- Source: Raw XML editor for advanced users

Also adds @automaker/spec-parser shared package for XML parsing
between server and client.

* fix: address PR review feedback

- Replace array index keys with stable UUIDs in array-field-editor,
  features-section, and roadmap-section components
- Replace regex-based XML parsing with fast-xml-parser for robustness
- Simplify renderContent logic in spec-view by removing dead code paths

* fix: convert git+ssh URLs to https in package-lock.json

* fix: address PR review feedback for spec visualiser

- Remove unused RefreshCw import from spec-view.tsx
- Add explicit parsedSpec check in renderContent for robustness
- Hide save button in view mode since it's read-only
- Remove GripVertical drag handles since drag-and-drop is not implemented
- Rename Map imports to MapIcon to avoid shadowing global Map
- Escape tagName in xml-utils.ts RegExp functions for safety
- Add aria-label attributes for accessibility on mode tabs

* fix: address additional PR review feedback

- Fix Textarea controlled/uncontrolled warning with default value
- Preserve IDs in useEffect sync to avoid unnecessary remounts
- Consolidate lucide-react imports
- Add JSDoc note about tag attributes limitation in xml-utils.ts
- Remove redundant disabled prop from SpecModeTabs
This commit is contained in:
Stefan de Vogelaere
2026-01-18 23:45:43 +01:00
committed by GitHub
parent af95dae73a
commit c4652190eb
29 changed files with 1994 additions and 85 deletions

View File

@@ -40,6 +40,7 @@
},
"dependencies": {
"@automaker/dependency-resolver": "1.0.0",
"@automaker/spec-parser": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",

View File

@@ -1,9 +1,6 @@
import CodeMirror from '@uiw/react-codemirror';
import { xml } from '@codemirror/lang-xml';
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { cn } from '@/lib/utils';
interface XmlSyntaxEditorProps {
@@ -14,52 +11,19 @@ interface XmlSyntaxEditorProps {
'data-testid'?: string;
}
// Syntax highlighting that uses CSS variables from the app's theme system
// This automatically adapts to any theme (dark, light, dracula, nord, etc.)
const syntaxColors = HighlightStyle.define([
// XML tags - use primary color
{ tag: t.tagName, color: 'var(--primary)' },
{ tag: t.angleBracket, color: 'var(--muted-foreground)' },
// Attributes
{ tag: t.attributeName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
// Strings and content
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
{ tag: t.content, color: 'var(--foreground)' },
// Comments
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
// Special
{ tag: t.processingInstruction, color: 'var(--muted-foreground)' },
{ tag: t.documentMeta, color: 'var(--muted-foreground)' },
]);
// Editor theme using CSS variables
// Simple editor theme - inherits text color from parent
const editorTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '0.875rem',
fontFamily: 'ui-monospace, monospace',
backgroundColor: 'transparent',
color: 'var(--foreground)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'ui-monospace, monospace',
},
'.cm-content': {
padding: '1rem',
minHeight: '100%',
caretColor: 'var(--primary)',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--primary)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
},
'.cm-activeLine': {
backgroundColor: 'transparent',
@@ -73,15 +37,8 @@ const editorTheme = EditorView.theme({
'.cm-gutters': {
display: 'none',
},
'.cm-placeholder': {
color: 'var(--muted-foreground)',
fontStyle: 'italic',
},
});
// Combine all extensions
const extensions: Extension[] = [xml(), syntaxHighlighting(syntaxColors), editorTheme];
export function XmlSyntaxEditor({
value,
onChange,
@@ -94,16 +51,16 @@ export function XmlSyntaxEditor({
<CodeMirror
value={value}
onChange={onChange}
extensions={extensions}
extensions={[xml(), editorTheme]}
theme="none"
placeholder={placeholder}
className="h-full [&_.cm-editor]:h-full"
className="h-full [&_.cm-editor]:h-full [&_.cm-content]:text-foreground"
basicSetup={{
lineNumbers: false,
foldGutter: false,
highlightActiveLine: false,
highlightSelectionMatches: true,
autocompletion: true,
highlightSelectionMatches: false,
autocompletion: false,
bracketMatching: true,
indentOnInput: true,
}}

View File

@@ -1,19 +1,32 @@
import { useState } from 'react';
import { useState, useCallback } from 'react';
import { useAppStore } from '@/store/app-store';
import { Spinner } from '@/components/ui/spinner';
// Extracted hooks
import { useSpecLoading, useSpecSave, useSpecGeneration } from './spec-view/hooks';
import { useSpecLoading, useSpecSave, useSpecGeneration, useSpecParser } from './spec-view/hooks';
// Extracted components
import { SpecHeader, SpecEditor, SpecEmptyState } from './spec-view/components';
import {
SpecHeader,
SpecEditor,
SpecEmptyState,
SpecViewMode,
SpecEditMode,
SpecModeTabs,
} from './spec-view/components';
// Extracted dialogs
import { CreateSpecDialog, RegenerateSpecDialog } from './spec-view/dialogs';
// Types
import type { SpecViewMode as SpecViewModeType } from './spec-view/types';
export function SpecView() {
const { currentProject, appSpec } = useAppStore();
// View mode state - default to 'view'
const [mode, setMode] = useState<SpecViewModeType>('view');
// Actions panel state (for tablet/mobile)
const [showActionsPanel, setShowActionsPanel] = useState(false);
@@ -21,7 +34,10 @@ export function SpecView() {
const { isLoading, specExists, isGenerationRunning, loadSpec } = useSpecLoading();
// Save state
const { isSaving, hasChanges, saveSpec, handleChange, setHasChanges } = useSpecSave();
const { isSaving, hasChanges, saveSpec, handleChange } = useSpecSave();
// Parse the spec XML
const { isValid: isParseValid, parsedSpec, errors: parseErrors } = useSpecParser(appSpec);
// Generation state and handlers
const {
@@ -70,8 +86,17 @@ export function SpecView() {
handleSync,
} = useSpecGeneration({ loadSpec });
// Reset hasChanges when spec is reloaded
// (This is needed because loadSpec updates appSpec in the store)
// Handle mode change - if parse is invalid, force source mode
const handleModeChange = useCallback(
(newMode: SpecViewModeType) => {
if ((newMode === 'view' || newMode === 'edit') && !isParseValid) {
// Can't switch to view/edit if parse is invalid
return;
}
setMode(newMode);
},
[isParseValid]
);
// No project selected
if (!currentProject) {
@@ -126,6 +151,28 @@ export function SpecView() {
);
}
// Render content based on mode
const renderContent = () => {
// If the XML is invalid or spec is not parsed, we can only show the source editor.
// The tabs for other modes are disabled, but this is an extra safeguard.
if (!isParseValid || !parsedSpec) {
return <SpecEditor value={appSpec} onChange={handleChange} />;
}
switch (mode) {
case 'view':
return <SpecViewMode spec={parsedSpec} />;
case 'edit':
return <SpecEditMode spec={parsedSpec} onChange={handleChange} />;
case 'source':
default:
return <SpecEditor value={appSpec} onChange={handleChange} />;
}
};
const isProcessing =
isRegenerating || isGenerationRunning || isCreating || isGeneratingFeatures || isSyncing;
// Main view - spec exists
return (
<div className="flex-1 flex flex-col overflow-hidden content-bg" data-testid="spec-view">
@@ -145,9 +192,33 @@ export function SpecView() {
onSaveClick={saveSpec}
showActionsPanel={showActionsPanel}
onToggleActionsPanel={() => setShowActionsPanel(!showActionsPanel)}
showSaveButton={mode !== 'view'}
/>
<SpecEditor value={appSpec} onChange={handleChange} />
{/* Mode tabs and content container */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mode tabs bar - inside the content area, centered */}
{!isProcessing && (
<div className="flex items-center justify-center px-4 py-2 border-b border-border bg-muted/30 relative">
<SpecModeTabs mode={mode} onModeChange={handleModeChange} isParseValid={isParseValid} />
{/* Show parse error indicator - positioned to the right */}
{!isParseValid && parseErrors.length > 0 && (
<span className="absolute right-4 text-xs text-destructive">
XML has errors - fix in Source mode
</span>
)}
</div>
)}
{/* Show parse error banner if in source mode with errors */}
{!isParseValid && parseErrors.length > 0 && mode === 'source' && (
<div className="px-4 py-2 bg-destructive/10 border-b border-destructive/20 text-sm text-destructive">
<span className="font-medium">XML Parse Errors:</span> {parseErrors.join(', ')}
</div>
)}
{renderContent()}
</div>
<RegenerateSpecDialog
open={showRegenerateDialog}

View File

@@ -0,0 +1,106 @@
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { useRef, useState, useEffect } from 'react';
interface ArrayFieldEditorProps {
values: string[];
onChange: (values: string[]) => void;
placeholder?: string;
addLabel?: string;
emptyMessage?: string;
}
interface ItemWithId {
id: string;
value: string;
}
function generateId(): string {
return crypto.randomUUID();
}
export function ArrayFieldEditor({
values,
onChange,
placeholder = 'Enter value...',
addLabel = 'Add Item',
emptyMessage = 'No items added yet.',
}: ArrayFieldEditorProps) {
// Track items with stable IDs
const [items, setItems] = useState<ItemWithId[]>(() =>
values.map((value) => ({ id: generateId(), value }))
);
// Track if we're making an internal change to avoid sync loops
const isInternalChange = useRef(false);
// Sync external values to internal items when values change externally
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return;
}
// External change - rebuild items with new IDs
setItems(values.map((value) => ({ id: generateId(), value })));
}, [values]);
const handleAdd = () => {
const newItems = [...items, { id: generateId(), value: '' }];
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map((item) => item.value));
};
const handleRemove = (id: string) => {
const newItems = items.filter((item) => item.id !== id);
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map((item) => item.value));
};
const handleChange = (id: string, value: string) => {
const newItems = items.map((item) => (item.id === id ? { ...item, value } : item));
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map((item) => item.value));
};
return (
<div className="space-y-2">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">{emptyMessage}</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<Card key={item.id} className="p-2">
<div className="flex items-center gap-2">
<Input
value={item.value}
onChange={(e) => handleChange(item.id, e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemove(item.id)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
</Card>
))}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
<Plus className="w-4 h-4" />
{addLabel}
</Button>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Lightbulb } from 'lucide-react';
import { ArrayFieldEditor } from './array-field-editor';
interface CapabilitiesSectionProps {
capabilities: string[];
onChange: (capabilities: string[]) => void;
}
export function CapabilitiesSection({ capabilities, onChange }: CapabilitiesSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Lightbulb className="w-5 h-5 text-primary" />
Core Capabilities
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={capabilities}
onChange={onChange}
placeholder="e.g., User authentication, Data visualization..."
addLabel="Add Capability"
emptyMessage="No capabilities defined. Add your core features."
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,261 @@
import { Plus, X, ChevronDown, ChevronUp, FolderOpen } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { ListChecks } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import type { SpecOutput } from '@automaker/spec-parser';
type Feature = SpecOutput['implemented_features'][number];
interface FeaturesSectionProps {
features: Feature[];
onChange: (features: Feature[]) => void;
}
interface FeatureWithId extends Feature {
_id: string;
_locationIds?: string[];
}
function generateId(): string {
return crypto.randomUUID();
}
function featureToInternal(feature: Feature): FeatureWithId {
return {
...feature,
_id: generateId(),
_locationIds: feature.file_locations?.map(() => generateId()),
};
}
function internalToFeature(internal: FeatureWithId): Feature {
const { _id, _locationIds, ...feature } = internal;
return feature;
}
interface FeatureCardProps {
feature: FeatureWithId;
index: number;
onChange: (feature: FeatureWithId) => void;
onRemove: () => void;
}
function FeatureCard({ feature, index, onChange, onRemove }: FeatureCardProps) {
const [isOpen, setIsOpen] = useState(false);
const handleNameChange = (name: string) => {
onChange({ ...feature, name });
};
const handleDescriptionChange = (description: string) => {
onChange({ ...feature, description });
};
const handleAddLocation = () => {
const locations = feature.file_locations || [];
const locationIds = feature._locationIds || [];
onChange({
...feature,
file_locations: [...locations, ''],
_locationIds: [...locationIds, generateId()],
});
};
const handleRemoveLocation = (locId: string) => {
const locationIds = feature._locationIds || [];
const idx = locationIds.indexOf(locId);
if (idx === -1) return;
const newLocations = feature.file_locations?.filter((_, i) => i !== idx);
const newLocationIds = locationIds.filter((id) => id !== locId);
onChange({
...feature,
file_locations: newLocations && newLocations.length > 0 ? newLocations : undefined,
_locationIds: newLocationIds.length > 0 ? newLocationIds : undefined,
});
};
const handleLocationChange = (locId: string, value: string) => {
const locationIds = feature._locationIds || [];
const idx = locationIds.indexOf(locId);
if (idx === -1) return;
const locations = [...(feature.file_locations || [])];
locations[idx] = value;
onChange({ ...feature, file_locations: locations });
};
return (
<Card className="border-border">
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center gap-2 p-3">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="p-1 h-auto">
{isOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</Button>
</CollapsibleTrigger>
<div className="flex-1 min-w-0">
<Input
value={feature.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Feature name..."
className="font-medium"
/>
</div>
<Badge variant="outline" className="shrink-0">
#{index + 1}
</Badge>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
<CollapsibleContent>
<div className="px-3 pb-3 space-y-4 border-t border-border pt-3 ml-10">
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={feature.description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Describe what this feature does..."
rows={3}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="flex items-center gap-1">
<FolderOpen className="w-4 h-4" />
File Locations
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddLocation}
className="gap-1 h-7"
>
<Plus className="w-3 h-3" />
Add
</Button>
</div>
{(feature.file_locations || []).length === 0 ? (
<p className="text-sm text-muted-foreground">No file locations specified.</p>
) : (
<div className="space-y-2">
{(feature.file_locations || []).map((location, idx) => {
const locId = feature._locationIds?.[idx] || `fallback-${idx}`;
return (
<div key={locId} className="flex items-center gap-2">
<Input
value={location}
onChange={(e) => handleLocationChange(locId, e.target.value)}
placeholder="e.g., src/components/feature.tsx"
className="flex-1 font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveLocation(locId)}
className="shrink-0 text-muted-foreground hover:text-destructive h-8 w-8"
>
<X className="w-3 h-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
export function FeaturesSection({ features, onChange }: FeaturesSectionProps) {
// Track features with stable IDs
const [items, setItems] = useState<FeatureWithId[]>(() => features.map(featureToInternal));
// Track if we're making an internal change to avoid sync loops
const isInternalChange = useRef(false);
// Sync external features to internal items when features change externally
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return;
}
setItems(features.map(featureToInternal));
}, [features]);
const handleAdd = () => {
const newItems = [...items, featureToInternal({ name: '', description: '' })];
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToFeature));
};
const handleRemove = (id: string) => {
const newItems = items.filter((item) => item._id !== id);
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToFeature));
};
const handleFeatureChange = (id: string, feature: FeatureWithId) => {
const newItems = items.map((item) => (item._id === id ? feature : item));
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToFeature));
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ListChecks className="w-5 h-5 text-primary" />
Implemented Features
<Badge variant="outline" className="ml-2">
{items.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No features added yet. Click below to add implemented features.
</p>
) : (
<div className="space-y-2">
{items.map((feature, index) => (
<FeatureCard
key={feature._id}
feature={feature}
index={index}
onChange={(f) => handleFeatureChange(feature._id, f)}
onRemove={() => handleRemove(feature._id)}
/>
))}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
<Plus className="w-4 h-4" />
Add Feature
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,7 @@
export { ArrayFieldEditor } from './array-field-editor';
export { ProjectInfoSection } from './project-info-section';
export { TechStackSection } from './tech-stack-section';
export { CapabilitiesSection } from './capabilities-section';
export { FeaturesSection } from './features-section';
export { RoadmapSection } from './roadmap-section';
export { RequirementsSection, GuidelinesSection } from './optional-sections';

View File

@@ -0,0 +1,59 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollText, Wrench } from 'lucide-react';
import { ArrayFieldEditor } from './array-field-editor';
interface RequirementsSectionProps {
requirements: string[];
onChange: (requirements: string[]) => void;
}
export function RequirementsSection({ requirements, onChange }: RequirementsSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ScrollText className="w-5 h-5 text-primary" />
Additional Requirements
<span className="text-sm font-normal text-muted-foreground">(Optional)</span>
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={requirements}
onChange={onChange}
placeholder="e.g., Node.js >= 18, Docker required..."
addLabel="Add Requirement"
emptyMessage="No additional requirements specified."
/>
</CardContent>
</Card>
);
}
interface GuidelinesSectionProps {
guidelines: string[];
onChange: (guidelines: string[]) => void;
}
export function GuidelinesSection({ guidelines, onChange }: GuidelinesSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Wrench className="w-5 h-5 text-primary" />
Development Guidelines
<span className="text-sm font-normal text-muted-foreground">(Optional)</span>
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={guidelines}
onChange={onChange}
placeholder="e.g., Follow TypeScript strict mode, use ESLint..."
addLabel="Add Guideline"
emptyMessage="No development guidelines specified."
/>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { FileText } from 'lucide-react';
interface ProjectInfoSectionProps {
projectName: string;
overview: string;
onProjectNameChange: (value: string) => void;
onOverviewChange: (value: string) => void;
}
export function ProjectInfoSection({
projectName,
overview,
onProjectNameChange,
onOverviewChange,
}: ProjectInfoSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="w-5 h-5 text-primary" />
Project Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name">Project Name</Label>
<Input
id="project-name"
value={projectName}
onChange={(e) => onProjectNameChange(e.target.value)}
placeholder="Enter project name..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="overview">Overview</Label>
<Textarea
id="overview"
value={overview}
onChange={(e) => onOverviewChange(e.target.value)}
placeholder="Describe what this project does, its purpose, and key goals..."
rows={5}
/>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,195 @@
import { Plus, X, Map as MapIcon } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { SpecOutput } from '@automaker/spec-parser';
type RoadmapPhase = NonNullable<SpecOutput['implementation_roadmap']>[number];
type PhaseStatus = 'completed' | 'in_progress' | 'pending';
interface PhaseWithId extends RoadmapPhase {
_id: string;
}
function generateId(): string {
return crypto.randomUUID();
}
function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
return { ...phase, _id: generateId() };
}
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
const { _id, ...phase } = internal;
return phase;
}
interface RoadmapSectionProps {
phases: RoadmapPhase[];
onChange: (phases: RoadmapPhase[]) => void;
}
interface PhaseCardProps {
phase: PhaseWithId;
onChange: (phase: PhaseWithId) => void;
onRemove: () => void;
}
function PhaseCard({ phase, onChange, onRemove }: PhaseCardProps) {
const handlePhaseNameChange = (name: string) => {
onChange({ ...phase, phase: name });
};
const handleStatusChange = (status: PhaseStatus) => {
onChange({ ...phase, status });
};
const handleDescriptionChange = (description: string) => {
onChange({ ...phase, description });
};
return (
<Card className="border-border">
<div className="p-3 space-y-3">
<div className="flex items-start gap-2">
<div className="flex-1 space-y-3">
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<Label className="sr-only">Phase Name</Label>
<Input
value={phase.phase}
onChange={(e) => handlePhaseNameChange(e.target.value)}
placeholder="Phase name..."
/>
</div>
<div className="w-full sm:w-40">
<Label className="sr-only">Status</Label>
<Select value={phase.status} onValueChange={handleStatusChange}>
<SelectTrigger>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="sr-only">Description</Label>
<Textarea
value={phase.description ?? ''}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Describe what this phase involves..."
rows={2}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
}
export function RoadmapSection({ phases, onChange }: RoadmapSectionProps) {
// Track phases with stable IDs
const [items, setItems] = useState<PhaseWithId[]>(() => phases.map(phaseToInternal));
// Track if we're making an internal change to avoid sync loops
const isInternalChange = useRef(false);
// Sync external phases to internal items when phases change externally
// Preserve existing IDs where possible to avoid unnecessary remounts
useEffect(() => {
if (isInternalChange.current) {
isInternalChange.current = false;
return;
}
setItems((currentItems) => {
return phases.map((phase, index) => {
// Try to find existing item by index (positional matching)
const existingItem = currentItems[index];
if (existingItem) {
// Reuse the existing ID, update the phase data
return { ...phase, _id: existingItem._id };
}
// New phase - generate new ID
return phaseToInternal(phase);
});
});
}, [phases]);
const handleAdd = () => {
const newItems = [...items, phaseToInternal({ phase: '', status: 'pending', description: '' })];
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToPhase));
};
const handleRemove = (id: string) => {
const newItems = items.filter((item) => item._id !== id);
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToPhase));
};
const handlePhaseChange = (id: string, phase: PhaseWithId) => {
const newItems = items.map((item) => (item._id === id ? phase : item));
setItems(newItems);
isInternalChange.current = true;
onChange(newItems.map(internalToPhase));
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<MapIcon className="w-5 h-5 text-primary" />
Implementation Roadmap
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No roadmap phases defined. Add phases to track implementation progress.
</p>
) : (
<div className="space-y-2">
{items.map((phase) => (
<PhaseCard
key={phase._id}
phase={phase}
onChange={(p) => handlePhaseChange(phase._id, p)}
onRemove={() => handleRemove(phase._id)}
/>
))}
</div>
)}
<Button type="button" variant="outline" size="sm" onClick={handleAdd} className="gap-1">
<Plus className="w-4 h-4" />
Add Phase
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Cpu } from 'lucide-react';
import { ArrayFieldEditor } from './array-field-editor';
interface TechStackSectionProps {
technologies: string[];
onChange: (technologies: string[]) => void;
}
export function TechStackSection({ technologies, onChange }: TechStackSectionProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Cpu className="w-5 h-5 text-primary" />
Technology Stack
</CardTitle>
</CardHeader>
<CardContent>
<ArrayFieldEditor
values={technologies}
onChange={onChange}
placeholder="e.g., React, TypeScript, Node.js..."
addLabel="Add Technology"
emptyMessage="No technologies added. Add your tech stack."
/>
</CardContent>
</Card>
);
}

View File

@@ -1,3 +1,6 @@
export { SpecHeader } from './spec-header';
export { SpecEditor } from './spec-editor';
export { SpecEmptyState } from './spec-empty-state';
export { SpecModeTabs } from './spec-mode-tabs';
export { SpecViewMode } from './spec-view-mode';
export { SpecEditMode } from './spec-edit-mode';

View File

@@ -0,0 +1,118 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import type { SpecOutput } from '@automaker/spec-parser';
import { specToXml } from '@automaker/spec-parser';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
ProjectInfoSection,
TechStackSection,
CapabilitiesSection,
FeaturesSection,
RoadmapSection,
RequirementsSection,
GuidelinesSection,
} from './edit-mode';
interface SpecEditModeProps {
spec: SpecOutput;
onChange: (xmlContent: string) => void;
}
export function SpecEditMode({ spec, onChange }: SpecEditModeProps) {
// Local state for form editing
const [formData, setFormData] = useState<SpecOutput>(spec);
// Track the last spec we synced FROM to detect external changes
const lastExternalSpecRef = useRef<string>(JSON.stringify(spec));
// Flag to prevent re-syncing when we caused the change
const isInternalChangeRef = useRef(false);
// Reset form only when spec changes externally (e.g., after save, sync, or regenerate)
useEffect(() => {
const specJson = JSON.stringify(spec);
// If we caused this change (internal), just update the ref and skip reset
if (isInternalChangeRef.current) {
lastExternalSpecRef.current = specJson;
isInternalChangeRef.current = false;
return;
}
// External change - reset form data
if (specJson !== lastExternalSpecRef.current) {
lastExternalSpecRef.current = specJson;
setFormData(spec);
}
}, [spec]);
// Update a field and notify parent
const updateField = useCallback(
<K extends keyof SpecOutput>(field: K, value: SpecOutput[K]) => {
setFormData((prev) => {
const newData = { ...prev, [field]: value };
// Mark as internal change before notifying parent
isInternalChangeRef.current = true;
const xmlContent = specToXml(newData);
onChange(xmlContent);
return newData;
});
},
[onChange]
);
return (
<ScrollArea className="flex-1">
<div className="p-4 space-y-6 max-w-4xl mx-auto">
{/* Project Information */}
<ProjectInfoSection
projectName={formData.project_name}
overview={formData.overview}
onProjectNameChange={(value) => updateField('project_name', value)}
onOverviewChange={(value) => updateField('overview', value)}
/>
{/* Technology Stack */}
<TechStackSection
technologies={formData.technology_stack}
onChange={(value) => updateField('technology_stack', value)}
/>
{/* Core Capabilities */}
<CapabilitiesSection
capabilities={formData.core_capabilities}
onChange={(value) => updateField('core_capabilities', value)}
/>
{/* Implemented Features */}
<FeaturesSection
features={formData.implemented_features}
onChange={(value) => updateField('implemented_features', value)}
/>
{/* Additional Requirements (Optional) */}
<RequirementsSection
requirements={formData.additional_requirements || []}
onChange={(value) =>
updateField('additional_requirements', value.length > 0 ? value : undefined)
}
/>
{/* Development Guidelines (Optional) */}
<GuidelinesSection
guidelines={formData.development_guidelines || []}
onChange={(value) =>
updateField('development_guidelines', value.length > 0 ? value : undefined)
}
/>
{/* Implementation Roadmap (Optional) */}
<RoadmapSection
phases={formData.implementation_roadmap || []}
onChange={(value) =>
updateField('implementation_roadmap', value.length > 0 ? value : undefined)
}
/>
</div>
</ScrollArea>
);
}

View File

@@ -8,8 +8,8 @@ interface SpecEditorProps {
export function SpecEditor({ value, onChange }: SpecEditorProps) {
return (
<div className="flex-1 p-4 overflow-hidden">
<Card className="h-full">
<div className="flex-1 p-4 overflow-hidden min-h-0">
<Card className="h-full overflow-hidden">
<XmlSyntaxEditor
value={value}
onChange={onChange}

View File

@@ -23,6 +23,8 @@ interface SpecHeaderProps {
onSaveClick: () => void;
showActionsPanel: boolean;
onToggleActionsPanel: () => void;
// Mode-related props for save button visibility
showSaveButton: boolean;
}
export function SpecHeader({
@@ -41,6 +43,7 @@ export function SpecHeader({
onSaveClick,
showActionsPanel,
onToggleActionsPanel,
showSaveButton,
}: SpecHeaderProps) {
const isProcessing = isRegenerating || isCreating || isGeneratingFeatures || isSyncing;
const phaseLabel = PHASE_LABELS[currentPhase] || currentPhase;
@@ -133,15 +136,17 @@ export function SpecHeader({
<ListPlus className="w-4 h-4 mr-2" />
Generate Features
</Button>
<Button
size="sm"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
{showSaveButton && (
<Button
size="sm"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
)}
</div>
)}
{/* Tablet/Mobile: show trigger for actions panel */}
@@ -212,15 +217,17 @@ export function SpecHeader({
<ListPlus className="w-4 h-4 mr-2" />
Generate Features
</Button>
<Button
className="w-full justify-start"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec-mobile"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
{showSaveButton && (
<Button
className="w-full justify-start"
onClick={onSaveClick}
disabled={!hasChanges || isSaving}
data-testid="save-spec-mobile"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? 'Saving...' : hasChanges ? 'Save Changes' : 'Saved'}
</Button>
)}
</>
)}
</HeaderActionsPanel>

View File

@@ -0,0 +1,55 @@
import { Eye, Edit3, Code } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { SpecViewMode } from '../types';
interface SpecModeTabsProps {
mode: SpecViewMode;
onModeChange: (mode: SpecViewMode) => void;
isParseValid: boolean;
disabled?: boolean;
}
export function SpecModeTabs({
mode,
onModeChange,
isParseValid,
disabled = false,
}: SpecModeTabsProps) {
const handleValueChange = (value: string) => {
onModeChange(value as SpecViewMode);
};
return (
<Tabs value={mode} onValueChange={handleValueChange}>
<TabsList>
<TabsTrigger
value="view"
disabled={disabled || !isParseValid}
title={!isParseValid ? 'Fix XML errors to enable View mode' : 'View formatted spec'}
aria-label="View"
>
<Eye className="w-4 h-4" />
<span className="hidden sm:inline">View</span>
</TabsTrigger>
<TabsTrigger
value="edit"
disabled={disabled || !isParseValid}
title={!isParseValid ? 'Fix XML errors to enable Edit mode' : 'Edit spec with form'}
aria-label="Edit"
>
<Edit3 className="w-4 h-4" />
<span className="hidden sm:inline">Edit</span>
</TabsTrigger>
<TabsTrigger
value="source"
disabled={disabled}
title="Edit raw XML source"
aria-label="Source"
>
<Code className="w-4 h-4" />
<span className="hidden sm:inline">Source</span>
</TabsTrigger>
</TabsList>
</Tabs>
);
}

View File

@@ -0,0 +1,223 @@
import type { SpecOutput } from '@automaker/spec-parser';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
CheckCircle2,
Circle,
Clock,
Cpu,
FileCode2,
FolderOpen,
Lightbulb,
ListChecks,
Map as MapIcon,
ScrollText,
Wrench,
} from 'lucide-react';
interface SpecViewModeProps {
spec: SpecOutput;
}
function StatusBadge({ status }: { status: 'completed' | 'in_progress' | 'pending' }) {
const variants = {
completed: { variant: 'success' as const, icon: CheckCircle2, label: 'Completed' },
in_progress: { variant: 'warning' as const, icon: Clock, label: 'In Progress' },
pending: { variant: 'muted' as const, icon: Circle, label: 'Pending' },
};
const { variant, icon: Icon, label } = variants[status];
return (
<Badge variant={variant} className="gap-1">
<Icon className="w-3 h-3" />
{label}
</Badge>
);
}
export function SpecViewMode({ spec }: SpecViewModeProps) {
return (
<ScrollArea className="h-full">
<div className="p-4 space-y-6 max-w-4xl mx-auto">
{/* Project Header */}
<div className="space-y-3">
<h1 className="text-3xl font-bold tracking-tight">{spec.project_name}</h1>
<p className="text-muted-foreground text-lg leading-relaxed">{spec.overview}</p>
</div>
{/* Technology Stack */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Cpu className="w-5 h-5 text-primary" />
Technology Stack
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{spec.technology_stack.map((tech, index) => (
<Badge key={index} variant="secondary" className="text-sm">
{tech}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* Core Capabilities */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Lightbulb className="w-5 h-5 text-primary" />
Core Capabilities
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{spec.core_capabilities.map((capability, index) => (
<li key={index} className="flex items-start gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500 mt-1 shrink-0" />
<span>{capability}</span>
</li>
))}
</ul>
</CardContent>
</Card>
{/* Implemented Features */}
{spec.implemented_features.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ListChecks className="w-5 h-5 text-primary" />
Implemented Features
<Badge variant="outline" className="ml-2">
{spec.implemented_features.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Accordion type="multiple" className="w-full">
{spec.implemented_features.map((feature, index) => (
<AccordionItem key={index} value={`feature-${index}`}>
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2 text-left">
<FileCode2 className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium">{feature.name}</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3 pl-6">
<p className="text-muted-foreground">{feature.description}</p>
{feature.file_locations && feature.file_locations.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium flex items-center gap-1">
<FolderOpen className="w-4 h-4" />
File Locations:
</p>
<ul className="text-sm text-muted-foreground space-y-1">
{feature.file_locations.map((loc, locIndex) => (
<li
key={locIndex}
className="font-mono text-xs bg-muted px-2 py-1 rounded"
>
{loc}
</li>
))}
</ul>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
)}
{/* Additional Requirements */}
{spec.additional_requirements && spec.additional_requirements.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ScrollText className="w-5 h-5 text-primary" />
Additional Requirements
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{spec.additional_requirements.map((req, index) => (
<li key={index} className="flex items-start gap-2">
<Circle className="w-2 h-2 text-muted-foreground mt-2 shrink-0 fill-current" />
<span>{req}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Development Guidelines */}
{spec.development_guidelines && spec.development_guidelines.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Wrench className="w-5 h-5 text-primary" />
Development Guidelines
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{spec.development_guidelines.map((guideline, index) => (
<li key={index} className="flex items-start gap-2">
<Circle className="w-2 h-2 text-muted-foreground mt-2 shrink-0 fill-current" />
<span>{guideline}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Implementation Roadmap */}
{spec.implementation_roadmap && spec.implementation_roadmap.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<MapIcon className="w-5 h-5 text-primary" />
Implementation Roadmap
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{spec.implementation_roadmap.map((phase, index) => (
<div
key={index}
className="flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-4 p-3 rounded-lg bg-muted/50"
>
<div className="flex items-center gap-2 sm:w-48 shrink-0">
<StatusBadge status={phase.status} />
</div>
<div className="flex-1">
<p className="font-medium">{phase.phase}</p>
<p className="text-sm text-muted-foreground">{phase.description}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
</ScrollArea>
);
}

View File

@@ -1,3 +1,5 @@
export { useSpecLoading } from './use-spec-loading';
export { useSpecSave } from './use-spec-save';
export { useSpecGeneration } from './use-spec-generation';
export { useSpecParser } from './use-spec-parser';
export type { UseSpecParserResult } from './use-spec-parser';

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import {
xmlToSpec,
isValidSpecXml,
type ParseResult,
type SpecOutput,
} from '@automaker/spec-parser';
/**
* Result of the spec parsing hook.
*/
export interface UseSpecParserResult {
/** Whether the XML is valid */
isValid: boolean;
/** The parsed spec object, or null if parsing failed */
parsedSpec: SpecOutput | null;
/** Parsing errors, if any */
errors: string[];
/** The full parse result */
parseResult: ParseResult | null;
}
/**
* Hook to parse XML spec content into a SpecOutput object.
* Memoizes the parsing result to avoid unnecessary re-parsing.
*
* @param xmlContent - The raw XML content from app_spec.txt
* @returns Parsed spec data with validation status
*/
export function useSpecParser(xmlContent: string): UseSpecParserResult {
return useMemo(() => {
if (!xmlContent || !xmlContent.trim()) {
return {
isValid: false,
parsedSpec: null,
errors: ['No spec content provided'],
parseResult: null,
};
}
// Quick structure check first
if (!isValidSpecXml(xmlContent)) {
return {
isValid: false,
parsedSpec: null,
errors: ['Invalid XML structure - missing required elements'],
parseResult: null,
};
}
// Full parse
const parseResult = xmlToSpec(xmlContent);
return {
isValid: parseResult.success,
parsedSpec: parseResult.spec,
errors: parseResult.errors,
parseResult,
};
}, [xmlContent]);
}

View File

@@ -1,3 +1,6 @@
// Spec view mode - determines how the spec is displayed/edited
export type SpecViewMode = 'view' | 'edit' | 'source';
// Feature count options for spec generation
export type FeatureCount = 20 | 50 | 100;

View File

@@ -0,0 +1,39 @@
{
"name": "@automaker/spec-parser",
"version": "1.0.0",
"type": "module",
"description": "XML spec parser for AutoMaker - parses and generates app_spec.txt XML",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"automaker",
"spec-parser",
"xml"
],
"author": "AutoMaker Team",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=22.0.0 <23.0.0"
},
"dependencies": {
"@automaker/types": "1.0.0",
"fast-xml-parser": "^5.3.3"
},
"devDependencies": {
"@types/node": "22.19.3",
"typescript": "5.9.3",
"vitest": "4.0.16"
}
}

View File

@@ -0,0 +1,26 @@
/**
* @automaker/spec-parser
*
* XML spec parser for AutoMaker - parses and generates app_spec.txt XML.
* This package provides utilities for:
* - Parsing XML spec content into SpecOutput objects
* - Converting SpecOutput objects back to XML
* - Validating spec data
*/
// Re-export types from @automaker/types for convenience
export type { SpecOutput } from '@automaker/types';
// XML utilities
export { escapeXml, unescapeXml, extractXmlSection, extractXmlElements } from './xml-utils.js';
// XML to Spec parsing
export { xmlToSpec } from './xml-to-spec.js';
export type { ParseResult } from './xml-to-spec.js';
// Spec to XML conversion
export { specToXml } from './spec-to-xml.js';
// Validation
export { validateSpec, isValidSpecXml } from './validate.js';
export type { ValidationResult } from './validate.js';

View File

@@ -0,0 +1,88 @@
/**
* SpecOutput to XML converter.
* Converts a structured SpecOutput object back to XML format.
*/
import type { SpecOutput } from '@automaker/types';
import { escapeXml } from './xml-utils.js';
/**
* Convert structured spec output to XML format.
*
* @param spec - The SpecOutput object to convert
* @returns XML string formatted for app_spec.txt
*/
export function specToXml(spec: SpecOutput): string {
const indent = ' ';
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<project_specification>
${indent}<project_name>${escapeXml(spec.project_name)}</project_name>
${indent}<overview>
${indent}${indent}${escapeXml(spec.overview)}
${indent}</overview>
${indent}<technology_stack>
${spec.technology_stack.map((t) => `${indent}${indent}<technology>${escapeXml(t)}</technology>`).join('\n')}
${indent}</technology_stack>
${indent}<core_capabilities>
${spec.core_capabilities.map((c) => `${indent}${indent}<capability>${escapeXml(c)}</capability>`).join('\n')}
${indent}</core_capabilities>
${indent}<implemented_features>
${spec.implemented_features
.map(
(f) => `${indent}${indent}<feature>
${indent}${indent}${indent}<name>${escapeXml(f.name)}</name>
${indent}${indent}${indent}<description>${escapeXml(f.description)}</description>${
f.file_locations && f.file_locations.length > 0
? `\n${indent}${indent}${indent}<file_locations>
${f.file_locations.map((loc) => `${indent}${indent}${indent}${indent}<location>${escapeXml(loc)}</location>`).join('\n')}
${indent}${indent}${indent}</file_locations>`
: ''
}
${indent}${indent}</feature>`
)
.join('\n')}
${indent}</implemented_features>`;
// Optional sections
if (spec.additional_requirements && spec.additional_requirements.length > 0) {
xml += `
${indent}<additional_requirements>
${spec.additional_requirements.map((r) => `${indent}${indent}<requirement>${escapeXml(r)}</requirement>`).join('\n')}
${indent}</additional_requirements>`;
}
if (spec.development_guidelines && spec.development_guidelines.length > 0) {
xml += `
${indent}<development_guidelines>
${spec.development_guidelines.map((g) => `${indent}${indent}<guideline>${escapeXml(g)}</guideline>`).join('\n')}
${indent}</development_guidelines>`;
}
if (spec.implementation_roadmap && spec.implementation_roadmap.length > 0) {
xml += `
${indent}<implementation_roadmap>
${spec.implementation_roadmap
.map(
(r) => `${indent}${indent}<phase>
${indent}${indent}${indent}<name>${escapeXml(r.phase)}</name>
${indent}${indent}${indent}<status>${escapeXml(r.status)}</status>
${indent}${indent}${indent}<description>${escapeXml(r.description)}</description>
${indent}${indent}</phase>`
)
.join('\n')}
${indent}</implementation_roadmap>`;
}
xml += `
</project_specification>`;
return xml;
}

View File

@@ -0,0 +1,143 @@
/**
* Validation utilities for SpecOutput objects.
*/
import type { SpecOutput } from '@automaker/types';
/**
* Validation result containing errors if any.
*/
export interface ValidationResult {
valid: boolean;
errors: string[];
}
/**
* Validate a SpecOutput object for required fields and data integrity.
*
* @param spec - The SpecOutput object to validate
* @returns ValidationResult with errors if validation fails
*/
export function validateSpec(spec: SpecOutput | null | undefined): ValidationResult {
const errors: string[] = [];
if (!spec) {
return { valid: false, errors: ['Spec is null or undefined'] };
}
// Required string fields
if (!spec.project_name || typeof spec.project_name !== 'string') {
errors.push('project_name is required and must be a string');
} else if (spec.project_name.trim().length === 0) {
errors.push('project_name cannot be empty');
}
if (!spec.overview || typeof spec.overview !== 'string') {
errors.push('overview is required and must be a string');
} else if (spec.overview.trim().length === 0) {
errors.push('overview cannot be empty');
}
// Required array fields
if (!Array.isArray(spec.technology_stack)) {
errors.push('technology_stack is required and must be an array');
} else if (spec.technology_stack.length === 0) {
errors.push('technology_stack must have at least one item');
} else if (spec.technology_stack.some((t) => typeof t !== 'string' || t.trim() === '')) {
errors.push('technology_stack items must be non-empty strings');
}
if (!Array.isArray(spec.core_capabilities)) {
errors.push('core_capabilities is required and must be an array');
} else if (spec.core_capabilities.length === 0) {
errors.push('core_capabilities must have at least one item');
} else if (spec.core_capabilities.some((c) => typeof c !== 'string' || c.trim() === '')) {
errors.push('core_capabilities items must be non-empty strings');
}
// Implemented features
if (!Array.isArray(spec.implemented_features)) {
errors.push('implemented_features is required and must be an array');
} else {
spec.implemented_features.forEach((f, i) => {
if (!f.name || typeof f.name !== 'string' || f.name.trim() === '') {
errors.push(`implemented_features[${i}].name is required and must be a non-empty string`);
}
if (!f.description || typeof f.description !== 'string') {
errors.push(`implemented_features[${i}].description is required and must be a string`);
}
if (f.file_locations !== undefined) {
if (!Array.isArray(f.file_locations)) {
errors.push(`implemented_features[${i}].file_locations must be an array if provided`);
} else if (f.file_locations.some((loc) => typeof loc !== 'string' || loc.trim() === '')) {
errors.push(`implemented_features[${i}].file_locations items must be non-empty strings`);
}
}
});
}
// Optional array fields
if (spec.additional_requirements !== undefined) {
if (!Array.isArray(spec.additional_requirements)) {
errors.push('additional_requirements must be an array if provided');
} else if (spec.additional_requirements.some((r) => typeof r !== 'string' || r.trim() === '')) {
errors.push('additional_requirements items must be non-empty strings');
}
}
if (spec.development_guidelines !== undefined) {
if (!Array.isArray(spec.development_guidelines)) {
errors.push('development_guidelines must be an array if provided');
} else if (spec.development_guidelines.some((g) => typeof g !== 'string' || g.trim() === '')) {
errors.push('development_guidelines items must be non-empty strings');
}
}
// Implementation roadmap
if (spec.implementation_roadmap !== undefined) {
if (!Array.isArray(spec.implementation_roadmap)) {
errors.push('implementation_roadmap must be an array if provided');
} else {
const validStatuses = ['completed', 'in_progress', 'pending'];
spec.implementation_roadmap.forEach((r, i) => {
if (!r.phase || typeof r.phase !== 'string' || r.phase.trim() === '') {
errors.push(
`implementation_roadmap[${i}].phase is required and must be a non-empty string`
);
}
if (!r.status || !validStatuses.includes(r.status)) {
errors.push(
`implementation_roadmap[${i}].status must be one of: ${validStatuses.join(', ')}`
);
}
if (!r.description || typeof r.description !== 'string') {
errors.push(`implementation_roadmap[${i}].description is required and must be a string`);
}
});
}
}
return { valid: errors.length === 0, errors };
}
/**
* Check if XML content appears to be a valid spec XML (basic structure check).
* This is a quick check, not a full validation.
*
* @param xmlContent - The XML content to check
* @returns true if the content appears to be valid spec XML
*/
export function isValidSpecXml(xmlContent: string): boolean {
if (!xmlContent || typeof xmlContent !== 'string') {
return false;
}
// Check for essential elements
const hasRoot = xmlContent.includes('<project_specification>');
const hasProjectName = /<project_name>[\s\S]*?<\/project_name>/.test(xmlContent);
const hasOverview = /<overview>[\s\S]*?<\/overview>/.test(xmlContent);
const hasTechStack = /<technology_stack>[\s\S]*?<\/technology_stack>/.test(xmlContent);
const hasCapabilities = /<core_capabilities>[\s\S]*?<\/core_capabilities>/.test(xmlContent);
return hasRoot && hasProjectName && hasOverview && hasTechStack && hasCapabilities;
}

View File

@@ -0,0 +1,232 @@
/**
* XML to SpecOutput parser.
* Parses app_spec.txt XML content into a structured SpecOutput object.
* Uses fast-xml-parser for robust XML parsing.
*/
import { XMLParser } from 'fast-xml-parser';
import type { SpecOutput } from '@automaker/types';
/**
* Result of parsing XML content.
*/
export interface ParseResult {
success: boolean;
spec: SpecOutput | null;
errors: string[];
}
// Configure the XML parser
const parser = new XMLParser({
ignoreAttributes: true,
trimValues: true,
// Preserve arrays for elements that can have multiple values
isArray: (name) => {
return [
'technology',
'capability',
'feature',
'location',
'requirement',
'guideline',
'phase',
].includes(name);
},
});
/**
* Safely get a string value from parsed XML, handling various input types.
*/
function getString(value: unknown): string {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number') return String(value);
if (value === null || value === undefined) return '';
return '';
}
/**
* Safely get an array of strings from parsed XML.
*/
function getStringArray(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) {
return value.map((item) => getString(item)).filter((s) => s.length > 0);
}
const str = getString(value);
return str ? [str] : [];
}
/**
* Parse implemented features from the parsed XML object.
*/
function parseImplementedFeatures(featuresSection: unknown): SpecOutput['implemented_features'] {
const features: SpecOutput['implemented_features'] = [];
if (!featuresSection || typeof featuresSection !== 'object') {
return features;
}
const section = featuresSection as Record<string, unknown>;
const featureList = section.feature;
if (!featureList) return features;
const featureArray = Array.isArray(featureList) ? featureList : [featureList];
for (const feature of featureArray) {
if (typeof feature !== 'object' || feature === null) continue;
const f = feature as Record<string, unknown>;
const name = getString(f.name);
const description = getString(f.description);
if (!name) continue;
const locationsSection = f.file_locations as Record<string, unknown> | undefined;
const file_locations = locationsSection ? getStringArray(locationsSection.location) : undefined;
features.push({
name,
description,
...(file_locations && file_locations.length > 0 ? { file_locations } : {}),
});
}
return features;
}
/**
* Parse implementation roadmap phases from the parsed XML object.
*/
function parseImplementationRoadmap(roadmapSection: unknown): SpecOutput['implementation_roadmap'] {
if (!roadmapSection || typeof roadmapSection !== 'object') {
return undefined;
}
const section = roadmapSection as Record<string, unknown>;
const phaseList = section.phase;
if (!phaseList) return undefined;
const phaseArray = Array.isArray(phaseList) ? phaseList : [phaseList];
const roadmap: NonNullable<SpecOutput['implementation_roadmap']> = [];
for (const phase of phaseArray) {
if (typeof phase !== 'object' || phase === null) continue;
const p = phase as Record<string, unknown>;
const phaseName = getString(p.name);
const statusRaw = getString(p.status);
const description = getString(p.description);
if (!phaseName) continue;
const status = (
['completed', 'in_progress', 'pending'].includes(statusRaw) ? statusRaw : 'pending'
) as 'completed' | 'in_progress' | 'pending';
roadmap.push({ phase: phaseName, status, description });
}
return roadmap.length > 0 ? roadmap : undefined;
}
/**
* Parse XML content into a SpecOutput object.
*
* @param xmlContent - The raw XML content from app_spec.txt
* @returns ParseResult with the parsed spec or errors
*/
export function xmlToSpec(xmlContent: string): ParseResult {
const errors: string[] = [];
// Check for root element before parsing
if (!xmlContent.includes('<project_specification>')) {
return {
success: false,
spec: null,
errors: ['Missing <project_specification> root element'],
};
}
// Parse the XML
let parsed: Record<string, unknown>;
try {
parsed = parser.parse(xmlContent) as Record<string, unknown>;
} catch (e) {
return {
success: false,
spec: null,
errors: [`XML parsing error: ${e instanceof Error ? e.message : 'Unknown error'}`],
};
}
const root = parsed.project_specification as Record<string, unknown> | undefined;
if (!root) {
return {
success: false,
spec: null,
errors: ['Missing <project_specification> root element'],
};
}
// Extract required fields
const project_name = getString(root.project_name);
if (!project_name) {
errors.push('Missing or empty <project_name>');
}
const overview = getString(root.overview);
if (!overview) {
errors.push('Missing or empty <overview>');
}
// Extract technology stack
const techSection = root.technology_stack as Record<string, unknown> | undefined;
const technology_stack = techSection ? getStringArray(techSection.technology) : [];
if (technology_stack.length === 0) {
errors.push('Missing or empty <technology_stack>');
}
// Extract core capabilities
const capSection = root.core_capabilities as Record<string, unknown> | undefined;
const core_capabilities = capSection ? getStringArray(capSection.capability) : [];
if (core_capabilities.length === 0) {
errors.push('Missing or empty <core_capabilities>');
}
// Extract implemented features
const implemented_features = parseImplementedFeatures(root.implemented_features);
// Extract optional sections
const reqSection = root.additional_requirements as Record<string, unknown> | undefined;
const additional_requirements = reqSection ? getStringArray(reqSection.requirement) : undefined;
const guideSection = root.development_guidelines as Record<string, unknown> | undefined;
const development_guidelines = guideSection ? getStringArray(guideSection.guideline) : undefined;
const implementation_roadmap = parseImplementationRoadmap(root.implementation_roadmap);
// Build spec object
const spec: SpecOutput = {
project_name,
overview,
technology_stack,
core_capabilities,
implemented_features,
...(additional_requirements && additional_requirements.length > 0
? { additional_requirements }
: {}),
...(development_guidelines && development_guidelines.length > 0
? { development_guidelines }
: {}),
...(implementation_roadmap ? { implementation_roadmap } : {}),
};
return {
success: errors.length === 0,
spec,
errors,
};
}

View File

@@ -0,0 +1,79 @@
/**
* XML utility functions for escaping, unescaping, and extracting XML content.
* These are pure functions with no dependencies for maximum reusability.
*/
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* Unescape XML entities back to regular characters.
*/
export function unescapeXml(str: string): string {
return str
.replace(/&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&gt;/g, '>')
.replace(/&lt;/g, '<')
.replace(/&amp;/g, '&');
}
/**
* Escape special RegExp characters in a string.
*/
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Extract the content of a specific XML section.
*
* Note: This function only matches bare tags without attributes.
* Tags with attributes (e.g., `<tag id="1">`) are not supported.
*
* @param xmlContent - The full XML content
* @param tagName - The tag name to extract (e.g., 'implemented_features')
* @returns The content between the tags, or null if not found
*/
export function extractXmlSection(xmlContent: string, tagName: string): string | null {
const safeTag = escapeRegExp(tagName);
const regex = new RegExp(`<${safeTag}>([\\s\\S]*?)<\\/${safeTag}>`, 'i');
const match = xmlContent.match(regex);
return match ? match[1] : null;
}
/**
* Extract all values from repeated XML elements.
*
* Note: This function only matches bare tags without attributes.
* Tags with attributes (e.g., `<tag id="1">`) are not supported.
*
* @param xmlContent - The XML content to search
* @param tagName - The tag name to extract values from
* @returns Array of extracted values (unescaped and trimmed)
*/
export function extractXmlElements(xmlContent: string, tagName: string): string[] {
const values: string[] = [];
const safeTag = escapeRegExp(tagName);
const regex = new RegExp(`<${safeTag}>([\\s\\S]*?)<\\/${safeTag}>`, 'g');
const matches = xmlContent.matchAll(regex);
for (const match of matches) {
values.push(unescapeXml(match[1].trim()));
}
return values;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

71
package-lock.json generated
View File

@@ -88,6 +88,7 @@
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@automaker/dependency-resolver": "1.0.0",
"@automaker/spec-parser": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "^6.12.1",
@@ -561,6 +562,33 @@
"undici-types": "~6.21.0"
}
},
"libs/spec-parser": {
"name": "@automaker/spec-parser",
"version": "1.0.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@automaker/types": "1.0.0",
"fast-xml-parser": "^5.3.3"
},
"devDependencies": {
"@types/node": "22.19.3",
"typescript": "5.9.3",
"vitest": "4.0.16"
},
"engines": {
"node": ">=22.0.0 <23.0.0"
}
},
"libs/spec-parser/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"libs/types": {
"name": "@automaker/types",
"version": "1.0.0",
@@ -656,6 +684,10 @@
"resolved": "apps/server",
"link": true
},
"node_modules/@automaker/spec-parser": {
"resolved": "libs/spec-parser",
"link": true
},
"node_modules/@automaker/types": {
"resolved": "libs/types",
"link": true
@@ -9724,6 +9756,24 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.3.tgz",
"integrity": "sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
@@ -11275,7 +11325,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11297,7 +11346,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11341,7 +11389,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11363,7 +11410,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11385,7 +11431,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11407,7 +11452,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11429,7 +11473,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11451,7 +11494,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11473,7 +11515,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -14888,6 +14929,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",

View File

@@ -30,7 +30,7 @@
"dev:docker:rebuild": "docker compose build --no-cache && docker compose up",
"dev:full": "npm run build:packages && concurrently \"npm run _dev:server\" \"npm run _dev:web\"",
"build": "npm run build:packages && npm run build --workspace=apps/ui",
"build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",
"build:packages": "npm run build -w @automaker/types && npm run build -w @automaker/platform && npm run build -w @automaker/utils -w @automaker/spec-parser && npm run build -w @automaker/prompts -w @automaker/model-resolver -w @automaker/dependency-resolver && npm run build -w @automaker/git-utils",
"build:server": "npm run build:packages && npm run build --workspace=apps/server",
"build:electron": "npm run build:packages && npm run build:electron --workspace=apps/ui",
"build:electron:dir": "npm run build:packages && npm run build:electron:dir --workspace=apps/ui",