Skip to content

Commit ab04a7f

Browse files
Add critic verification reporting & progress UI
Track and expose Critic verification and rejected-test details across the CLI and progress page. GenerateHandler: collect verified and rejected tests, provide callbacks from VerifyTestsAsync, build VerifiedTest/RejectedTest payloads, write intermediate and final result files (including verification/rejected fields), update progress messages and session counts, and only block generation when appropriate. GapAnalyzer: improve focus matching by splitting focus into terms and checking title/path/preview/headings against each term. ProgressPageWriter: replace meta refresh with JS-driven auto-reload, add verification and rejected-tests sections/UI, support vscode:// file link handling and safer URI encoding, and adjust styling. ProgressPhases: add "verifying" phase to generation. Tests updated to expect the new auto-refresh script. Also bump Spectra.CLI and Spectra.MCP versions to 1.32.7 and add a new SKILL.md plus manifest entry for docs indexing.
1 parent 2802693 commit ab04a7f

File tree

11 files changed

+523
-65
lines changed

11 files changed

+523
-65
lines changed

src/Spectra.CLI/Commands/Generate/GenerateHandler.cs

Lines changed: 190 additions & 31 deletions
Large diffs are not rendered by default.

src/Spectra.CLI/Coverage/GapAnalyzer.cs

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -207,28 +207,28 @@ private static GapSeverity EstimateSeverity(DocumentEntry doc)
207207

208208
private static bool MatchesFocus(DocumentEntry doc, string focusArea)
209209
{
210-
var lowerFocus = focusArea.ToLowerInvariant();
211-
212-
// Check title, path, and preview for focus keywords
213-
if (doc.Title.ToLowerInvariant().Contains(lowerFocus))
214-
{
215-
return true;
216-
}
210+
// Split focus into individual terms (e.g., "security, high priority" → ["security", "high priority"])
211+
var focusTerms = focusArea.ToLowerInvariant()
212+
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
213+
.Where(t => t.Length > 0)
214+
.ToList();
217215

218-
if (doc.Path.ToLowerInvariant().Contains(lowerFocus))
219-
{
220-
return true;
221-
}
216+
var docTitle = doc.Title.ToLowerInvariant();
217+
var docPath = doc.Path.ToLowerInvariant();
218+
var docPreview = doc.Preview.ToLowerInvariant();
222219

223-
if (doc.Preview.ToLowerInvariant().Contains(lowerFocus))
220+
foreach (var term in focusTerms)
224221
{
225-
return true;
226-
}
222+
if (docTitle.Contains(term) || docPath.Contains(term) || docPreview.Contains(term))
223+
{
224+
return true;
225+
}
227226

228-
// Check headings
229-
if (doc.Headings?.Any(h => h.ToLowerInvariant().Contains(lowerFocus)) == true)
230-
{
231-
return true;
227+
// Check headings
228+
if (doc.Headings?.Any(h => h.ToLowerInvariant().Contains(term)) == true)
229+
{
230+
return true;
231+
}
232232
}
233233

234234
return false;

src/Spectra.CLI/Progress/ProgressPageWriter.cs

Lines changed: 194 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public static void OpenInBrowser(string htmlPath)
4848

4949
private static string BuildHtml(string jsonData, bool isTerminal, string workspaceRoot, string? title = null)
5050
{
51-
var refreshTag = isTerminal ? "" : """<meta http-equiv="refresh" content="2">""";
51+
var refreshTag = ""; // Refresh handled by JavaScript below
5252
var escapedJson = HttpUtility.HtmlEncode(jsonData);
5353
var escapedRoot = HttpUtility.HtmlEncode(workspaceRoot.Replace('\\', '/'));
5454

@@ -295,6 +295,99 @@ .breakdown h3 {
295295
color: var(--color-primary);
296296
}
297297
298+
/* Verification progress */
299+
.verification-section {
300+
background: var(--color-card);
301+
border-radius: 12px;
302+
padding: 1.5rem;
303+
border: 1px solid var(--color-border);
304+
margin-bottom: 1.5rem;
305+
}
306+
.verification-section h3 {
307+
font-size: 0.9rem;
308+
font-weight: 600;
309+
margin-bottom: 1rem;
310+
}
311+
.verification-item {
312+
font-size: 0.8rem;
313+
padding: 8px 12px;
314+
border-radius: 6px;
315+
margin-bottom: 4px;
316+
display: flex;
317+
flex-wrap: wrap;
318+
align-items: center;
319+
gap: 8px;
320+
border-left: 3px solid transparent;
321+
}
322+
.verdict-grounded { background: var(--color-passed-bg); border-left-color: var(--color-passed); }
323+
.verdict-partial { background: #fef9c3; border-left-color: #ca8a04; }
324+
.verdict-hallucinated { background: var(--color-failed-bg); border-left-color: var(--color-failed); }
325+
.verdict-icon { font-weight: 700; font-size: 0.9rem; }
326+
.verdict-grounded .verdict-icon { color: var(--color-passed); }
327+
.verdict-partial .verdict-icon { color: #ca8a04; }
328+
.verdict-hallucinated .verdict-icon { color: var(--color-failed); }
329+
.verdict-id {
330+
font-family: 'JetBrains Mono', monospace;
331+
font-weight: 600;
332+
color: var(--color-text);
333+
}
334+
.verdict-title { color: var(--color-text); flex: 1; min-width: 0; }
335+
.verdict-badge {
336+
font-size: 0.7rem;
337+
font-weight: 600;
338+
text-transform: uppercase;
339+
padding: 2px 8px;
340+
border-radius: 10px;
341+
letter-spacing: 0.03em;
342+
}
343+
.verdict-grounded .verdict-badge { background: var(--color-passed); color: white; }
344+
.verdict-partial .verdict-badge { background: #ca8a04; color: white; }
345+
.verdict-hallucinated .verdict-badge { background: var(--color-failed); color: white; }
346+
.verdict-reason {
347+
width: 100%;
348+
font-size: 0.75rem;
349+
color: var(--color-text-muted);
350+
font-style: italic;
351+
padding-left: 24px;
352+
}
353+
354+
/* Rejected tests */
355+
.rejected-section {
356+
background: var(--color-card);
357+
border-radius: 12px;
358+
padding: 1.5rem;
359+
border: 1px solid var(--color-border);
360+
margin-bottom: 1.5rem;
361+
}
362+
.rejected-section h3 {
363+
font-size: 0.9rem;
364+
font-weight: 600;
365+
margin-bottom: 1rem;
366+
color: var(--color-failed);
367+
}
368+
.rejected-item {
369+
font-size: 0.8rem;
370+
padding: 8px 10px;
371+
background: var(--color-failed-bg);
372+
border-radius: 6px;
373+
margin-bottom: 4px;
374+
border-left: 3px solid var(--color-failed);
375+
}
376+
.rejected-id {
377+
font-family: 'JetBrains Mono', monospace;
378+
font-weight: 600;
379+
color: var(--color-failed);
380+
}
381+
.rejected-title {
382+
color: var(--color-text);
383+
}
384+
.rejected-reason {
385+
font-size: 0.75rem;
386+
color: var(--color-text-muted);
387+
margin-top: 4px;
388+
font-style: italic;
389+
}
390+
298391
/* Error */
299392
.error-card {
300393
background: var(--color-failed-bg);
@@ -378,8 +471,26 @@ .breakdown h3 {
378471
{{BuildBody(jsonData, isTerminal, workspaceRoot)}}
379472
</div>
380473
<script>
381-
// VS Code Live Preview auto-reloads when the file changes on disk.
382-
// No custom polling needed — the CLI rewrites this HTML on every progress update.
474+
// Auto-refresh: reload page every 1.5s while status is not terminal.
475+
// Uses JavaScript instead of <meta refresh> for reliable file:// support.
476+
(function() {
477+
var isTerminal = {{(isTerminal ? "true" : "false")}};
478+
if (!isTerminal) {
479+
setInterval(function() {
480+
window.location.reload();
481+
}, 1500);
482+
}
483+
})();
484+
485+
// File links: open vscode:// URIs via JavaScript click handler
486+
// (browsers block direct <a href="vscode://..."> from file:// pages)
487+
document.addEventListener('click', function(e) {
488+
var link = e.target.closest('[data-vscode-uri]');
489+
if (link) {
490+
e.preventDefault();
491+
window.location.href = link.getAttribute('data-vscode-uri');
492+
}
493+
});
383494
</script>
384495
</body>
385496
</html>
@@ -429,6 +540,12 @@ private static string BuildBody(string jsonData, bool isTerminal, string workspa
429540
}
430541
}
431542

543+
// Verification progress
544+
if (root.TryGetProperty("verification", out var verification) && verification.GetArrayLength() > 0)
545+
{
546+
sb.Append(BuildVerificationSection(verification));
547+
}
548+
432549
// Docs index summary cards
433550
if (root.TryGetProperty("documents_total", out var dt2))
434551
{
@@ -518,6 +635,12 @@ private static string BuildBody(string jsonData, bool isTerminal, string workspa
518635
""");
519636
}
520637

638+
// Rejected tests
639+
if (root.TryGetProperty("rejected_tests", out var rejectedTests) && rejectedTests.GetArrayLength() > 0)
640+
{
641+
sb.Append(BuildRejectedTestsSection(rejectedTests));
642+
}
643+
521644
// Files created
522645
if (root.TryGetProperty("files_created", out var files) && files.GetArrayLength() > 0)
523646
{
@@ -531,7 +654,7 @@ private static string BuildBody(string jsonData, bool isTerminal, string workspa
531654
<div class="footer">
532655
<span class="refresh-indicator">
533656
<span class="refresh-dot"></span>
534-
Auto-refreshing every 2 seconds
657+
Auto-refreshing every 1.5 seconds
535658
</span>
536659
</div>
537660
""");
@@ -759,9 +882,74 @@ private static string BuildFilesSection(System.Text.Json.JsonElement files, stri
759882
{
760883
var relativePath = file.GetString() ?? "";
761884
var fullPath = Path.GetFullPath(Path.Combine(workspaceRoot, relativePath)).Replace('\\', '/');
762-
var vscodeUri = $"vscode://file/{fullPath}";
885+
var vscodeUri = $"vscode://file/{Uri.EscapeDataString(fullPath).Replace("%2F", "/")}";
886+
887+
sb.Append($"""<a class="file-item file-link" href="{Escape(vscodeUri)}" data-vscode-uri="{Escape(vscodeUri)}" title="Open in VS Code"><span class="file-icon">📄</span>{Escape(relativePath)}</a>""");
888+
}
889+
890+
sb.Append("</div>");
891+
return sb.ToString();
892+
}
893+
894+
private static string BuildVerificationSection(System.Text.Json.JsonElement verification)
895+
{
896+
var sb = new StringBuilder();
897+
sb.Append("""<div class="verification-section"><h3>Critic Verification</h3><div class="verification-list">""");
898+
899+
foreach (var test in verification.EnumerateArray())
900+
{
901+
var id = test.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? "" : "";
902+
var title = test.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? "" : "";
903+
var verdict = test.TryGetProperty("verdict", out var verdictProp) ? verdictProp.GetString() ?? "" : "";
904+
var score = test.TryGetProperty("score", out var scoreProp) ? scoreProp.GetDouble() : 0;
905+
var reason = test.TryGetProperty("reason", out var reasonProp) ? reasonProp.GetString() : null;
763906

764-
sb.Append($"""<a class="file-item file-link" href="{Escape(vscodeUri)}" title="Open in VS Code"><span class="file-icon">📄</span>{Escape(relativePath)}</a>""");
907+
var verdictClass = verdict switch
908+
{
909+
"grounded" => "verdict-grounded",
910+
"partial" => "verdict-partial",
911+
"hallucinated" => "verdict-hallucinated",
912+
_ => "verdict-unknown"
913+
};
914+
915+
var verdictIcon = verdict switch
916+
{
917+
"grounded" => "&#x2713;", // checkmark
918+
"partial" => "&#x25CB;", // circle
919+
"hallucinated" => "&#x2717;", // cross
920+
_ => "?"
921+
};
922+
923+
sb.Append($"""<div class="verification-item {verdictClass}">""");
924+
sb.Append($"""<span class="verdict-icon">{verdictIcon}</span>""");
925+
sb.Append($"""<span class="verdict-id">{Escape(id)}</span>""");
926+
sb.Append($"""<span class="verdict-title">{Escape(title)}</span>""");
927+
sb.Append($"""<span class="verdict-badge">{Escape(verdict)}</span>""");
928+
if (!string.IsNullOrEmpty(reason))
929+
sb.Append($"""<div class="verdict-reason">{Escape(reason)}</div>""");
930+
sb.Append("</div>");
931+
}
932+
933+
sb.Append("</div></div>");
934+
return sb.ToString();
935+
}
936+
937+
private static string BuildRejectedTestsSection(System.Text.Json.JsonElement rejected)
938+
{
939+
var sb = new StringBuilder();
940+
sb.Append("""<div class="rejected-section"><h3>Rejected by Critic</h3>""");
941+
942+
foreach (var test in rejected.EnumerateArray())
943+
{
944+
var id = test.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? "" : "";
945+
var title = test.TryGetProperty("title", out var titleProp) ? titleProp.GetString() ?? "" : "";
946+
var verdict = test.TryGetProperty("verdict", out var verdictProp) ? verdictProp.GetString() ?? "" : "";
947+
var reason = test.TryGetProperty("reason", out var reasonProp) ? reasonProp.GetString() : null;
948+
949+
sb.Append($"""<div class="rejected-item"><span class="rejected-id">{Escape(id)}</span> <span class="rejected-title">{Escape(title)}</span>""");
950+
if (!string.IsNullOrEmpty(reason))
951+
sb.Append($"""<div class="rejected-reason">{Escape(reason)}</div>""");
952+
sb.Append("</div>");
765953
}
766954

767955
sb.Append("</div>");

src/Spectra.CLI/Progress/ProgressPhases.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ namespace Spectra.CLI.Progress;
66
public static class ProgressPhases
77
{
88
public static readonly string[] Generate =
9-
["analyzing", "analyzed", "generating", "completed"];
9+
["analyzing", "analyzed", "generating", "verifying", "completed"];
1010

1111
public static readonly string[] Update =
1212
["classifying", "updating", "verifying", "completed"];

src/Spectra.CLI/Results/GenerateResult.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ public sealed class GenerateResult : CommandResult
2525
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
2626
public IReadOnlyList<DuplicateWarning>? DuplicateWarnings { get; init; }
2727

28+
[JsonPropertyName("verification")]
29+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
30+
public IReadOnlyList<VerifiedTest>? Verification { get; init; }
31+
32+
[JsonPropertyName("rejected_tests")]
33+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
34+
public IReadOnlyList<RejectedTest>? RejectedTests { get; init; }
35+
2836
[JsonPropertyName("session")]
2937
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
3038
public SessionCounts? Session { get; init; }
@@ -106,6 +114,41 @@ public sealed class DuplicateWarning
106114
public required string Title { get; init; }
107115
}
108116

117+
public sealed class VerifiedTest
118+
{
119+
[JsonPropertyName("id")]
120+
public required string Id { get; init; }
121+
122+
[JsonPropertyName("title")]
123+
public required string Title { get; init; }
124+
125+
[JsonPropertyName("verdict")]
126+
public required string Verdict { get; init; }
127+
128+
[JsonPropertyName("score")]
129+
public double Score { get; init; }
130+
131+
[JsonPropertyName("reason")]
132+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
133+
public string? Reason { get; init; }
134+
}
135+
136+
public sealed class RejectedTest
137+
{
138+
[JsonPropertyName("id")]
139+
public required string Id { get; init; }
140+
141+
[JsonPropertyName("title")]
142+
public required string Title { get; init; }
143+
144+
[JsonPropertyName("verdict")]
145+
public required string Verdict { get; init; }
146+
147+
[JsonPropertyName("reason")]
148+
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
149+
public string? Reason { get; init; }
150+
}
151+
109152
public sealed class SessionCounts
110153
{
111154
[JsonPropertyName("from_docs")]

src/Spectra.CLI/Spectra.CLI.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
<PackAsTool>true</PackAsTool>
2828
<ToolCommandName>spectra</ToolCommandName>
2929
<PackageId>Spectra.CLI</PackageId>
30-
<Version>1.32.5</Version>
30+
<Version>1.32.7</Version>
3131
<Authors>Spectra</Authors>
3232
<Description>AI-native test generation and management CLI</Description>
3333
<PackageOutputPath>./nupkg</PackageOutputPath>

src/Spectra.MCP/Spectra.MCP.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<PackAsTool>true</PackAsTool>
1111
<ToolCommandName>spectra-mcp</ToolCommandName>
1212
<PackageId>Spectra.MCP</PackageId>
13-
<Version>1.32.5</Version>
13+
<Version>1.32.7</Version>
1414
<Authors>Spectra</Authors>
1515
<Description>Spectra MCP Server for test execution</Description>
1616
<PackageOutputPath>./nupkg</PackageOutputPath>

0 commit comments

Comments
 (0)