Skip to content

Commit 8a05fe7

Browse files
Implement 015-auto-requirements-extraction
AI-powered extraction of testable requirements from documentation. New RequirementsWriter handles YAML merge, duplicate detection (normalized title + substring matching), sequential ID allocation (REQ-NNN), and atomic file writes. RequirementsExtractor uses Copilot SDK with RFC 2119 priority inference. CLI: spectra ai analyze --extract-requirements [--dry-run]
1 parent 934abeb commit 8a05fe7

File tree

9 files changed

+827
-23
lines changed

9 files changed

+827
-23
lines changed

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ spectra ai analyze --coverage --format json --output coverage.json
135135
spectra ai analyze --coverage --format markdown --output coverage.md
136136
spectra ai analyze --coverage --auto-link # Write automated_by back into test files
137137
spectra ai analyze --coverage --verbosity detailed
138+
spectra ai analyze --extract-requirements # Extract requirements from docs
139+
spectra ai analyze --extract-requirements --dry-run # Preview without writing
138140

139141
# Automation Directory Management (013-cli-ux-improvements)
140142
spectra config add-automation-dir ../new-tests # Add automation dir for coverage
@@ -151,6 +153,7 @@ spectra config list-automation-dirs # List dirs with existence s
151153
- **Tests:** xUnit with structured results (never throw on validation errors)
152154

153155
## Recent Changes
156+
- 015-auto-requirements-extraction: ✅ COMPLETE - AI-powered extraction of testable requirements from documentation. New models: `ExtractionResult`, `DuplicateMatch`. New service: `RequirementsWriter` handles YAML merge, duplicate detection (normalized title + substring matching), sequential ID allocation (REQ-NNN, never reuse gaps), atomic writes. New `RequirementsExtractor` uses Copilot SDK to extract requirements with RFC 2119 priority inference. CLI: `spectra ai analyze --extract-requirements [--dry-run]`. 11 new RequirementsWriter tests.
154157
- 014-open-source-ready: ✅ COMPLETE - Open source readiness. README redesign with banner placeholder, shields.io badges, value props, feature showcase, quickstart. CI pipeline (`.github/workflows/ci.yml`) — build+test on push/PR. NuGet publish pipeline (`.github/workflows/publish.yml`) — tag-triggered pack+push for Spectra.CLI and Spectra.MCP. All 1071 tests passing (fixed parallel test isolation with `[Collection("WorkingDirectory")]`). GitHub issue templates (bug report, feature request), PR template, Dependabot config. All README doc links verified.
155158
- 013-cli-ux-improvements: ✅ COMPLETE - CLI UX improvements for discoverability and workflow. New `NextStepHints` helper prints context-aware next-step suggestions after every command (init, generate, analyze, dashboard, validate, docs index, index) in dimmed text, suppressed by `--quiet` or piped output. Init flow: new interactive prompts for automation directory setup (`coverage.automation_dirs`) and critic model configuration (`ai.critic`). New config subcommands: `spectra config add-automation-dir`, `remove-automation-dir`, `list-automation-dirs`. Interactive generation mode: continuation menu after suite completion (generate more, switch suite, create suite, exit) with session summary. 18 new tests.
156159
- 012-dashboard-branding: ✅ COMPLETE - Dashboard branding and theming customization. New models: `BrandingConfig`, `ColorPaletteConfig` in `DashboardConfig`. New service: `BrandingInjector` handles company name, logo, favicon, CSS variable overrides, dark theme, and custom CSS injection via template placeholders. `SampleDataFactory` provides mock data for `--preview` mode. Light/dark theme presets via CSS custom properties. Config: `dashboard.branding` section in spectra.config.json with `company_name`, `logo`, `favicon`, `theme`, `colors`, `custom_css`. CLI: `spectra dashboard --preview` for branding verification. 51 new tests (8 config + 21 injector + 9 sample data + 13 existing generator).

specs/015-auto-requirements-extraction/tasks.md

Lines changed: 17 additions & 17 deletions
Large diffs are not rendered by default.
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using System.Text.Json;
2+
using GitHub.Copilot.SDK;
3+
using Spectra.Core.Models.Coverage;
4+
using SpectraProviderConfig = Spectra.Core.Models.Config.ProviderConfig;
5+
6+
namespace Spectra.CLI.Agent.Copilot;
7+
8+
/// <summary>
9+
/// Extracts testable requirements from documentation using the Copilot SDK.
10+
/// </summary>
11+
public sealed class RequirementsExtractor
12+
{
13+
private readonly SpectraProviderConfig _provider;
14+
private readonly Action<string>? _onStatus;
15+
16+
private readonly string _basePath;
17+
18+
public RequirementsExtractor(SpectraProviderConfig provider, string basePath, Action<string>? onStatus = null)
19+
{
20+
_provider = provider;
21+
_basePath = basePath;
22+
_onStatus = onStatus;
23+
}
24+
25+
/// <summary>
26+
/// Extracts testable requirements from source documents.
27+
/// </summary>
28+
public async Task<IReadOnlyList<RequirementDefinition>> ExtractAsync(
29+
IReadOnlyList<Spectra.Core.Models.DocumentEntry> documents,
30+
IReadOnlyList<RequirementDefinition> existingRequirements,
31+
CancellationToken ct = default)
32+
{
33+
if (documents.Count == 0)
34+
return [];
35+
36+
try
37+
{
38+
var service = await CopilotService.GetInstanceAsync(ct);
39+
40+
_onStatus?.Invoke("Creating extraction session...");
41+
await using var session = await service.CreateGenerationSessionAsync(
42+
_provider,
43+
ct: ct);
44+
45+
var prompt = await BuildExtractionPromptAsync(documents, existingRequirements, ct);
46+
47+
_onStatus?.Invoke("Extracting requirements from documentation...");
48+
var response = await session.SendAndWaitAsync(
49+
new MessageOptions { Prompt = prompt },
50+
timeout: TimeSpan.FromMinutes(3),
51+
cancellationToken: ct);
52+
53+
var responseText = response?.Data?.Content ?? "";
54+
return ParseResponse(responseText);
55+
}
56+
catch (Exception ex)
57+
{
58+
_onStatus?.Invoke($"Extraction failed: {ex.Message}");
59+
return [];
60+
}
61+
}
62+
63+
private async Task<string> BuildExtractionPromptAsync(
64+
IReadOnlyList<Spectra.Core.Models.DocumentEntry> documents,
65+
IReadOnlyList<RequirementDefinition> existing,
66+
CancellationToken ct)
67+
{
68+
var docParts = new List<string>();
69+
foreach (var doc in documents)
70+
{
71+
var fullPath = Path.Combine(_basePath, doc.Path);
72+
var content = File.Exists(fullPath)
73+
? await File.ReadAllTextAsync(fullPath, ct)
74+
: doc.Preview;
75+
docParts.Add($"## Document: {doc.Path}\n\n{content}");
76+
}
77+
var docContent = string.Join("\n\n---\n\n", docParts);
78+
79+
var existingTitles = existing.Count > 0
80+
? "Already extracted requirements (DO NOT duplicate these):\n" +
81+
string.Join("\n", existing.Select(r => $"- {r.Id}: {r.Title}"))
82+
: "No existing requirements.";
83+
84+
return $$"""
85+
You are a requirements analyst. Extract all testable behavioral requirements from the documentation below.
86+
87+
For each requirement:
88+
- title: A concise statement of the testable behavior (e.g., "System locks account after 5 failed login attempts")
89+
- source: The document path where this behavior is described
90+
- priority: Based on RFC 2119 language:
91+
- "high" if the document uses MUST, SHALL, REQUIRED, CRITICAL, or similar mandatory language
92+
- "medium" if the document uses SHOULD, RECOMMENDED, or similar advisory language
93+
- "low" if the document uses MAY, OPTIONAL, NICE TO HAVE, or similar permissive language
94+
- Default to "medium" if no clear priority language is present
95+
96+
{{existingTitles}}
97+
98+
Respond ONLY with a JSON array. No markdown, no explanation. Example:
99+
[
100+
{"title": "User can reset password via email", "source": "docs/auth.md", "priority": "high"},
101+
{"title": "System displays warning on weak password", "source": "docs/auth.md", "priority": "medium"}
102+
]
103+
104+
Documentation:
105+
{{docContent}}
106+
""";
107+
}
108+
109+
private static IReadOnlyList<RequirementDefinition> ParseResponse(string responseText)
110+
{
111+
if (string.IsNullOrWhiteSpace(responseText))
112+
return [];
113+
114+
try
115+
{
116+
// Extract JSON array from response (may have markdown code fences)
117+
var jsonStart = responseText.IndexOf('[');
118+
var jsonEnd = responseText.LastIndexOf(']');
119+
120+
if (jsonStart < 0 || jsonEnd < 0 || jsonEnd <= jsonStart)
121+
return [];
122+
123+
var jsonText = responseText[jsonStart..(jsonEnd + 1)];
124+
125+
var items = JsonSerializer.Deserialize<List<ExtractedItem>>(jsonText, new JsonSerializerOptions
126+
{
127+
PropertyNameCaseInsensitive = true
128+
});
129+
130+
if (items is null)
131+
return [];
132+
133+
return items
134+
.Where(i => !string.IsNullOrWhiteSpace(i.Title))
135+
.Select(i => new RequirementDefinition
136+
{
137+
Title = i.Title!.Trim(),
138+
Source = i.Source?.Trim(),
139+
Priority = NormalizePriority(i.Priority)
140+
})
141+
.ToList();
142+
}
143+
catch
144+
{
145+
return [];
146+
}
147+
}
148+
149+
private static string NormalizePriority(string? priority)
150+
{
151+
if (string.IsNullOrWhiteSpace(priority))
152+
return "medium";
153+
154+
return priority.Trim().ToLowerInvariant() switch
155+
{
156+
"high" => "high",
157+
"low" => "low",
158+
_ => "medium"
159+
};
160+
}
161+
162+
private sealed class ExtractedItem
163+
{
164+
public string? Title { get; set; }
165+
public string? Source { get; set; }
166+
public string? Priority { get; set; }
167+
}
168+
}

src/Spectra.CLI/Commands/Analyze/AnalyzeCommand.cs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,43 @@ public AnalyzeCommand() : base("analyze", "Analyze test coverage across document
2929
["--auto-link"],
3030
"Scan automation code and update test automated_by fields");
3131

32+
var extractRequirementsOption = new Option<bool>(
33+
["--extract-requirements"],
34+
"Extract testable requirements from documentation");
35+
3236
AddOption(outputOption);
3337
AddOption(formatOption);
3438
AddOption(coverageOption);
3539
AddOption(autoLinkOption);
40+
AddOption(extractRequirementsOption);
3641

3742
this.SetHandler(async (context) =>
3843
{
3944
var output = context.ParseResult.GetValueForOption(outputOption);
4045
var format = context.ParseResult.GetValueForOption(formatOption);
4146
var coverage = context.ParseResult.GetValueForOption(coverageOption);
4247
var autoLink = context.ParseResult.GetValueForOption(autoLinkOption);
48+
var extractReqs = context.ParseResult.GetValueForOption(extractRequirementsOption);
4349
var verbosity = context.ParseResult.GetValueForOption(GlobalOptions.VerbosityOption);
50+
var dryRun = context.ParseResult.GetValueForOption(GlobalOptions.DryRunOption);
4451

4552
var handler = new AnalyzeHandler(verbosity);
46-
context.ExitCode = await handler.ExecuteAsync(
47-
output,
48-
format,
49-
coverage,
50-
autoLink,
51-
context.GetCancellationToken());
53+
54+
if (extractReqs)
55+
{
56+
context.ExitCode = await handler.RunExtractRequirementsAsync(
57+
dryRun,
58+
context.GetCancellationToken());
59+
}
60+
else
61+
{
62+
context.ExitCode = await handler.ExecuteAsync(
63+
output,
64+
format,
65+
coverage,
66+
autoLink,
67+
context.GetCancellationToken());
68+
}
5269
});
5370
}
5471
}

src/Spectra.CLI/Commands/Analyze/AnalyzeHandler.cs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,144 @@ await RunAutoLinkAsync(
202202
}
203203
}
204204

205+
/// <summary>
206+
/// Extracts testable requirements from documentation using AI.
207+
/// </summary>
208+
public async Task<int> RunExtractRequirementsAsync(
209+
bool dryRun,
210+
CancellationToken ct = default)
211+
{
212+
try
213+
{
214+
var currentDir = Directory.GetCurrentDirectory();
215+
var configPath = Path.Combine(currentDir, "spectra.config.json");
216+
217+
// Load config
218+
SpectraConfig? config = null;
219+
if (File.Exists(configPath))
220+
{
221+
var configJson = await File.ReadAllTextAsync(configPath, ct);
222+
config = JsonSerializer.Deserialize<SpectraConfig>(configJson, new JsonSerializerOptions
223+
{
224+
PropertyNameCaseInsensitive = true
225+
});
226+
}
227+
228+
if (config is null)
229+
{
230+
Console.Error.WriteLine("No spectra.config.json found. Run 'spectra init' first.");
231+
return ExitCodes.Error;
232+
}
233+
234+
// Auto-refresh document index
235+
var indexService = new DocumentIndexService();
236+
await indexService.EnsureIndexAsync(currentDir, config.Source, forceRebuild: false, ct);
237+
238+
// Load source documents
239+
var docBuilder = new DocumentMapBuilder();
240+
var documentMap = await docBuilder.BuildAsync(currentDir, ct);
241+
242+
if (documentMap.Documents.Count == 0)
243+
{
244+
Console.WriteLine("No documentation files found. Add docs to your source directory.");
245+
return ExitCodes.Success;
246+
}
247+
248+
// Load existing requirements
249+
var reqsPath = Path.Combine(currentDir, config.Coverage.RequirementsFile);
250+
var parser = new Spectra.Core.Parsing.RequirementsParser();
251+
var existing = await parser.ParseAsync(reqsPath, ct);
252+
253+
// Get primary provider
254+
var provider = config.Ai.Providers.FirstOrDefault(p => p.Enabled);
255+
if (provider is null)
256+
{
257+
Console.Error.WriteLine("No AI provider configured. Run 'spectra init' to configure.");
258+
return ExitCodes.Error;
259+
}
260+
261+
// Extract requirements
262+
if (_verbosity >= VerbosityLevel.Normal)
263+
{
264+
Console.WriteLine($"Extracting requirements from {documentMap.Documents.Count} document(s)...");
265+
}
266+
267+
var extractor = new Agent.Copilot.RequirementsExtractor(
268+
provider,
269+
currentDir,
270+
_verbosity >= VerbosityLevel.Normal ? Console.WriteLine : null);
271+
272+
var extracted = await extractor.ExtractAsync(documentMap.Documents, existing, ct);
273+
274+
if (extracted.Count == 0)
275+
{
276+
Console.WriteLine("No requirements found in documentation.");
277+
return ExitCodes.Success;
278+
}
279+
280+
// Merge and write
281+
var writer = new Spectra.Core.Parsing.RequirementsWriter();
282+
var result = writer.DetectDuplicates(existing, extracted);
283+
284+
if (dryRun)
285+
{
286+
Console.WriteLine();
287+
Console.WriteLine($"Extracted {extracted.Count} requirement(s) (dry run — no files written):");
288+
Console.WriteLine();
289+
290+
var withIds = writer.AllocateIds(existing, result.Merged);
291+
foreach (var req in withIds)
292+
{
293+
Console.WriteLine($" {req.Id} [{req.Priority}] {req.Title}");
294+
Console.WriteLine($" Source: {req.Source}");
295+
}
296+
297+
if (result.Duplicates.Count > 0)
298+
{
299+
Console.WriteLine();
300+
Console.WriteLine($" {result.Duplicates.Count} duplicate(s) would be skipped");
301+
}
302+
303+
return ExitCodes.Success;
304+
}
305+
306+
var writeResult = await writer.MergeAndWriteAsync(reqsPath, extracted, ct);
307+
308+
// Report results
309+
Console.WriteLine();
310+
Console.WriteLine($"Requirements extraction complete:");
311+
Console.WriteLine($" New: {writeResult.Merged.Count}");
312+
Console.WriteLine($" Duplicates: {writeResult.SkippedCount} (skipped)");
313+
Console.WriteLine($" Total: {writeResult.TotalInFile}");
314+
Console.WriteLine($" File: {Path.GetRelativePath(currentDir, reqsPath)}");
315+
316+
if (writeResult.Merged.Count > 0 && _verbosity >= VerbosityLevel.Normal)
317+
{
318+
Console.WriteLine();
319+
foreach (var req in writeResult.Merged)
320+
{
321+
Console.WriteLine($" + {req.Id} [{req.Priority}] {req.Title}");
322+
}
323+
}
324+
325+
return ExitCodes.Success;
326+
}
327+
catch (OperationCanceledException)
328+
{
329+
Console.WriteLine("\nOperation cancelled.");
330+
return ExitCodes.Cancelled;
331+
}
332+
catch (Exception ex)
333+
{
334+
Console.Error.WriteLine($"Error: {ex.Message}");
335+
if (_verbosity >= VerbosityLevel.Detailed)
336+
{
337+
Console.Error.WriteLine(ex.StackTrace);
338+
}
339+
return ExitCodes.Error;
340+
}
341+
}
342+
205343
/// <summary>
206344
/// Legacy doc-only coverage mode (when --coverage is not specified).
207345
/// </summary>

0 commit comments

Comments
 (0)