feat(agent/qa): post-run Poindexter workspace analysis per RFC §7 (#538)
Per RFC §7 Post-Run Analysis: analyseWorkspace() builds 5D Poindexter
points (tool_id, severity_score, file_hash, category_id, frequency),
clusters by distance 0.15, diffs against previous journal entries to
classify New / Resolved / Persistent (≥5 consecutive cycles).
Lands:
* pkg/agentic/qa_analysis.go — analyseWorkspace, DispatchReport,
findingToPoint, diffFindings, persistentFindings; integrates with
forge.lthn.ai/Snider/Poindexter (canonical path per memory)
* pkg/agentic/qa.go — wires analysis into runQAWithReport before
ws.Commit() (sync.go untouched — ws.Commit lives in runQAWithReport
in this branch)
* journal publication extended so summary text + analysis fields travel
with the report
* qa_analysis_test.go — TestAnalyseWorkspace_{Good_EmptyFindings,
Good_FiveClusters,Bad_NilWorkspace,Ugly_PoindexterPanic}; the panic
test uses a panic-injecting clusterer override and asserts graceful
recovery
* go.mod — adds forge.lthn.ai/Snider/Poindexter (canonical, NOT
dappco.re — Poindexter is OG load-bearing math primitive)
Sandbox go test blocked by pre-existing unrelated issues in
commands_forge.go / fetch_loop.go / commands_flow_test.go (out of
allowlist); supervisor catches in clean workspace.
Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=538
This commit is contained in:
parent
53b46c33da
commit
5c942a8928
4 changed files with 525 additions and 71 deletions
3
go.mod
3
go.mod
|
|
@ -10,6 +10,7 @@ require (
|
|||
dappco.re/go/process v0.8.0-alpha.1
|
||||
dappco.re/go/store v0.8.0-alpha.1
|
||||
dappco.re/go/ws v0.8.0-alpha.1
|
||||
forge.lthn.ai/Snider/Poindexter v0.0.0-20260223032814-5ab751f16d06
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||
|
|
@ -137,3 +138,5 @@ require (
|
|||
)
|
||||
|
||||
replace dappco.re/go/mcp => ../mcp
|
||||
|
||||
replace forge.lthn.ai/Snider/Poindexter => ../../snider/Poindexter
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ type DispatchReport struct {
|
|||
Workspace string `json:"workspace"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Summary map[string]any `json:"summary"`
|
||||
SummaryText string `json:"summary_text,omitempty"`
|
||||
Findings []QAFinding `json:"findings,omitempty"`
|
||||
Tools []QAToolRun `json:"tools,omitempty"`
|
||||
BuildPassed bool `json:"build_passed"`
|
||||
|
|
@ -283,22 +284,12 @@ func (s *PrepSubsystem) runQAWithReport(ctx context.Context, workspaceDir string
|
|||
lintPassed := report.Summary.Errors == 0
|
||||
|
||||
workspaceName := WorkspaceName(workspaceDir)
|
||||
previousCycles := readPreviousJournalCycles(storeInstance, workspaceName, persistentThreshold)
|
||||
|
||||
dispatchReport := DispatchReport{
|
||||
Workspace: workspaceName,
|
||||
Summary: workspace.Aggregate(),
|
||||
Findings: report.Findings,
|
||||
Tools: report.Tools,
|
||||
BuildPassed: buildPassed,
|
||||
TestPassed: testPassed,
|
||||
LintPassed: lintPassed,
|
||||
Passed: buildPassed && testPassed,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
Clusters: clusterFindings(report.Findings),
|
||||
}
|
||||
|
||||
dispatchReport.New, dispatchReport.Resolved, dispatchReport.Persistent = diffFindingsAgainstJournal(report.Findings, previousCycles)
|
||||
dispatchReport := s.analyseWorkspaceNamed(workspace, workspaceName)
|
||||
dispatchReport.BuildPassed = buildPassed
|
||||
dispatchReport.TestPassed = testPassed
|
||||
dispatchReport.LintPassed = lintPassed
|
||||
dispatchReport.Passed = buildPassed && testPassed
|
||||
dispatchReport.GeneratedAt = time.Now().UTC()
|
||||
|
||||
writeDispatchReport(workspaceDir, dispatchReport)
|
||||
|
||||
|
|
@ -351,8 +342,13 @@ func publishDispatchReport(storeInstance *store.Store, workspaceName string, dis
|
|||
"test_passed": dispatchReport.TestPassed,
|
||||
"lint_passed": dispatchReport.LintPassed,
|
||||
"summary": dispatchReport.Summary,
|
||||
"summary_text": dispatchReport.SummaryText,
|
||||
"findings": findings,
|
||||
"tools": tools,
|
||||
"clusters": dispatchReport.Clusters,
|
||||
"new": dispatchReport.New,
|
||||
"resolved": dispatchReport.Resolved,
|
||||
"persistent": dispatchReport.Persistent,
|
||||
"generated_at": dispatchReport.GeneratedAt.Format(time.RFC3339Nano),
|
||||
}
|
||||
tags := map[string]string{"workspace": workspaceName}
|
||||
|
|
@ -565,61 +561,8 @@ func findingToMap(finding QAFinding) map[string]any {
|
|||
//
|
||||
// Usage example: `newList, resolvedList, persistentList := diffFindingsAgainstJournal(current, previous)`
|
||||
func diffFindingsAgainstJournal(current []QAFinding, previous [][]map[string]any) (newList, resolvedList, persistentList []map[string]any) {
|
||||
if len(previous) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
currentByKey := make(map[string]QAFinding, len(current))
|
||||
for _, finding := range current {
|
||||
currentByKey[findingFingerprint(finding)] = finding
|
||||
}
|
||||
|
||||
lastCycle := previous[len(previous)-1]
|
||||
lastCycleByKey := make(map[string]map[string]any, len(lastCycle))
|
||||
for _, entry := range lastCycle {
|
||||
lastCycleByKey[findingFingerprintFromMap(entry)] = entry
|
||||
}
|
||||
|
||||
for key, finding := range currentByKey {
|
||||
if _, ok := lastCycleByKey[key]; !ok {
|
||||
newList = append(newList, findingToMap(finding))
|
||||
}
|
||||
}
|
||||
|
||||
for key, entry := range lastCycleByKey {
|
||||
if _, ok := currentByKey[key]; !ok {
|
||||
resolvedList = append(resolvedList, entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Persistent findings must appear in every one of the last
|
||||
// `persistentThreshold` cycles AND in the current cycle. We slice from the
|
||||
// tail so shorter histories still participate — as the journal grows past
|
||||
// the threshold the list becomes stricter.
|
||||
window := previous
|
||||
if len(window) > persistentThreshold-1 {
|
||||
window = window[len(window)-(persistentThreshold-1):]
|
||||
}
|
||||
if len(window) == persistentThreshold-1 {
|
||||
counts := make(map[string]int, len(currentByKey))
|
||||
for _, cycle := range window {
|
||||
seen := make(map[string]bool, len(cycle))
|
||||
for _, entry := range cycle {
|
||||
key := findingFingerprintFromMap(entry)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
counts[key]++
|
||||
}
|
||||
}
|
||||
for key, finding := range currentByKey {
|
||||
if counts[key] == len(window) {
|
||||
persistentList = append(persistentList, findingToMap(finding))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newList, resolvedList = diffFindings(current, previous)
|
||||
persistentList = persistentFindings(current, previous)
|
||||
return newList, resolvedList, persistentList
|
||||
}
|
||||
|
||||
|
|
|
|||
371
pkg/agentic/qa_analysis.go
Normal file
371
pkg/agentic/qa_analysis.go
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package agentic
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
store "dappco.re/go/store"
|
||||
poindexter "forge.lthn.ai/Snider/Poindexter"
|
||||
)
|
||||
|
||||
const qaAnalysisClusterDistance = 0.15
|
||||
|
||||
type qaAnalysisPoint struct {
|
||||
Index int
|
||||
ToolID float64
|
||||
Severity float64
|
||||
FileHash float64
|
||||
Category float64
|
||||
Frequency float64
|
||||
}
|
||||
|
||||
var qaAnalysisClusterer = qaAnalysisClusters
|
||||
|
||||
// analyseWorkspace reads the buffered QA findings from the workspace DuckDB
|
||||
// and returns the RFC §7 dispatch report. When called without the caller's
|
||||
// original workspace name, the journal comparison falls back to the QA buffer
|
||||
// name with the `qa-` prefix removed.
|
||||
//
|
||||
// Usage example: `report := s.analyseWorkspace(workspace)`
|
||||
func (s *PrepSubsystem) analyseWorkspace(workspace *store.Workspace) DispatchReport {
|
||||
return s.analyseWorkspaceNamed(workspace, qaAnalysisMeasurementName(qaAnalysisWorkspaceName(workspace)))
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) analyseWorkspaceNamed(workspace *store.Workspace, workspaceName string) DispatchReport {
|
||||
report := DispatchReport{
|
||||
Workspace: core.Trim(workspaceName),
|
||||
Summary: map[string]any{},
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
}
|
||||
if report.Workspace == "" {
|
||||
report.Workspace = qaAnalysisMeasurementName(qaAnalysisWorkspaceName(workspace))
|
||||
}
|
||||
if workspace == nil {
|
||||
report.SummaryText = qaAnalysisSummaryText(report)
|
||||
return report
|
||||
}
|
||||
|
||||
report.Findings = qaAnalysisWorkspaceFindings(workspace)
|
||||
report.Tools = qaAnalysisWorkspaceToolRuns(workspace)
|
||||
report.Clusters = qaAnalysisSafeClusters(report.Findings)
|
||||
|
||||
previousCycles := readPreviousJournalCycles(s.stateStoreInstance(), report.Workspace, persistentThreshold)
|
||||
report.New, report.Resolved = diffFindings(report.Findings, previousCycles)
|
||||
report.Persistent = persistentFindings(report.Findings, previousCycles)
|
||||
report.Summary = qaAnalysisSummary(workspace.Aggregate(), report)
|
||||
report.SummaryText = qaAnalysisSummaryText(report)
|
||||
return report
|
||||
}
|
||||
|
||||
func qaAnalysisWorkspaceName(workspace *store.Workspace) string {
|
||||
if workspace == nil {
|
||||
return ""
|
||||
}
|
||||
return workspace.Name()
|
||||
}
|
||||
|
||||
func qaAnalysisMeasurementName(name string) string {
|
||||
trimmed := core.Trim(name)
|
||||
if core.HasPrefix(trimmed, "qa-") {
|
||||
return core.TrimPrefix(trimmed, "qa-")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func qaAnalysisWorkspaceFindings(workspace *store.Workspace) []QAFinding {
|
||||
rows := qaAnalysisWorkspaceRows(workspace, "finding")
|
||||
findings := make([]QAFinding, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
var finding QAFinding
|
||||
if parseResult := core.JSONUnmarshalString(row, &finding); parseResult.OK {
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
func qaAnalysisWorkspaceToolRuns(workspace *store.Workspace) []QAToolRun {
|
||||
rows := qaAnalysisWorkspaceRows(workspace, "tool_run")
|
||||
toolRuns := make([]QAToolRun, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
var toolRun QAToolRun
|
||||
if parseResult := core.JSONUnmarshalString(row, &toolRun); parseResult.OK {
|
||||
toolRuns = append(toolRuns, toolRun)
|
||||
}
|
||||
}
|
||||
return toolRuns
|
||||
}
|
||||
|
||||
func qaAnalysisWorkspaceRows(workspace *store.Workspace, kind string) []string {
|
||||
if workspace == nil || kind == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := workspace.Query(
|
||||
core.Sprintf(
|
||||
"SELECT data FROM entries WHERE kind = '%s' ORDER BY id",
|
||||
escapeJournalLiteral(kind),
|
||||
),
|
||||
)
|
||||
if !result.OK || result.Value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, ok := result.Value.([]map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if payload := stringValue(row["data"]); payload != "" {
|
||||
values = append(values, payload)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// findingToPoint projects a finding into the RFC §7 clustering dimensions.
|
||||
// Frequency defaults to 1 for direct callers; the cluster builder supplies the
|
||||
// observed per-fingerprint frequency for each point.
|
||||
//
|
||||
// Usage example: `coords := findingToPoint(QAFinding{Tool: "gosec", Severity: "error", File: "main.go", Category: "security"})`
|
||||
func findingToPoint(finding QAFinding) []float64 {
|
||||
return qaAnalysisPointCoords(finding, 1)
|
||||
}
|
||||
|
||||
func qaAnalysisPointCoords(finding QAFinding, frequency float64) []float64 {
|
||||
return []float64{
|
||||
qaAnalysisHash(core.Lower(finding.Tool)),
|
||||
qaAnalysisSeverityScore(finding.Severity),
|
||||
qaAnalysisHash(core.Lower(finding.File)),
|
||||
qaAnalysisHash(core.Lower(firstNonEmpty(finding.Category, finding.Code, finding.RuleID))),
|
||||
frequency,
|
||||
}
|
||||
}
|
||||
|
||||
func diffFindings(current []QAFinding, previous [][]map[string]any) (newList, resolvedList []map[string]any) {
|
||||
if len(previous) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
currentByKey := make(map[string]QAFinding, len(current))
|
||||
for _, finding := range current {
|
||||
currentByKey[findingFingerprint(finding)] = finding
|
||||
}
|
||||
|
||||
lastCycle := previous[len(previous)-1]
|
||||
lastCycleByKey := make(map[string]map[string]any, len(lastCycle))
|
||||
for _, entry := range lastCycle {
|
||||
lastCycleByKey[findingFingerprintFromMap(entry)] = entry
|
||||
}
|
||||
|
||||
for key, finding := range currentByKey {
|
||||
if _, ok := lastCycleByKey[key]; !ok {
|
||||
newList = append(newList, findingToMap(finding))
|
||||
}
|
||||
}
|
||||
|
||||
for key, entry := range lastCycleByKey {
|
||||
if _, ok := currentByKey[key]; !ok {
|
||||
resolvedList = append(resolvedList, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return newList, resolvedList
|
||||
}
|
||||
|
||||
func persistentFindings(current []QAFinding, previous [][]map[string]any) []map[string]any {
|
||||
if len(previous) < persistentThreshold-1 || len(current) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
currentByKey := make(map[string]QAFinding, len(current))
|
||||
for _, finding := range current {
|
||||
currentByKey[findingFingerprint(finding)] = finding
|
||||
}
|
||||
|
||||
window := previous
|
||||
if len(window) > persistentThreshold-1 {
|
||||
window = window[len(window)-(persistentThreshold-1):]
|
||||
}
|
||||
|
||||
counts := make(map[string]int, len(currentByKey))
|
||||
for _, cycle := range window {
|
||||
seen := make(map[string]bool, len(cycle))
|
||||
for _, entry := range cycle {
|
||||
key := findingFingerprintFromMap(entry)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
counts[key]++
|
||||
}
|
||||
}
|
||||
|
||||
persistentList := make([]map[string]any, 0, len(currentByKey))
|
||||
for key, finding := range currentByKey {
|
||||
if counts[key] == len(window) {
|
||||
persistentList = append(persistentList, findingToMap(finding))
|
||||
}
|
||||
}
|
||||
if len(persistentList) == 0 {
|
||||
return nil
|
||||
}
|
||||
return persistentList
|
||||
}
|
||||
|
||||
func qaAnalysisSafeClusters(findings []QAFinding) (clusters []DispatchCluster) {
|
||||
if len(findings) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
core.Warn("agentic: Poindexter workspace analysis panicked", "reason", recovered)
|
||||
clusters = clusterFindingsFallback(findings)
|
||||
}
|
||||
}()
|
||||
|
||||
clusters = qaAnalysisClusterer(findings)
|
||||
if len(clusters) > 0 {
|
||||
return clusters
|
||||
}
|
||||
return clusterFindingsFallback(findings)
|
||||
}
|
||||
|
||||
func qaAnalysisClusters(findings []QAFinding) []DispatchCluster {
|
||||
if len(findings) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
frequencies := qaAnalysisFrequencies(findings)
|
||||
items := make([]qaAnalysisPoint, len(findings))
|
||||
for index, finding := range findings {
|
||||
coords := qaAnalysisPointCoords(finding, frequencies[findingFingerprint(finding)])
|
||||
items[index] = qaAnalysisPoint{
|
||||
Index: index,
|
||||
ToolID: coords[0],
|
||||
Severity: coords[1],
|
||||
FileHash: coords[2],
|
||||
Category: coords[3],
|
||||
Frequency: coords[4],
|
||||
}
|
||||
}
|
||||
|
||||
points, err := poindexter.BuildND(items,
|
||||
func(item qaAnalysisPoint) string { return core.Sprintf("finding-%d", item.Index) },
|
||||
[]func(qaAnalysisPoint) float64{
|
||||
func(item qaAnalysisPoint) float64 { return item.ToolID },
|
||||
func(item qaAnalysisPoint) float64 { return item.Severity },
|
||||
func(item qaAnalysisPoint) float64 { return item.FileHash },
|
||||
func(item qaAnalysisPoint) float64 { return item.Category },
|
||||
func(item qaAnalysisPoint) float64 { return item.Frequency },
|
||||
},
|
||||
[]float64{1, 1, 1, 1, 1},
|
||||
[]bool{false, false, false, false, false},
|
||||
)
|
||||
if err != nil || len(points) == 0 {
|
||||
return clusterFindingsFallback(findings)
|
||||
}
|
||||
|
||||
union := qaAnalysisClusterByDistance(points, findings, qaAnalysisClusterDistance)
|
||||
if union == nil {
|
||||
return clusterFindingsFallback(findings)
|
||||
}
|
||||
return qaClusterDispatchClusters(findings, union)
|
||||
}
|
||||
|
||||
func qaAnalysisClusterByDistance(points []poindexter.KDPoint[qaAnalysisPoint], findings []QAFinding, distance float64) *qaClusterUnion {
|
||||
tree, err := poindexter.NewKDTree(points, poindexter.WithMetric(poindexter.EuclideanDistance{}))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
union := newQAClusterUnion(len(points))
|
||||
for _, point := range points {
|
||||
neighbours, _ := tree.Radius(point.Coords, distance)
|
||||
for _, neighbour := range neighbours {
|
||||
leftIndex := point.Value.Index
|
||||
rightIndex := neighbour.Value.Index
|
||||
if leftIndex == rightIndex {
|
||||
continue
|
||||
}
|
||||
if !qaAnalysisCompatible(findings[leftIndex], findings[rightIndex]) {
|
||||
continue
|
||||
}
|
||||
union.Union(leftIndex, rightIndex)
|
||||
}
|
||||
}
|
||||
return union
|
||||
}
|
||||
|
||||
func qaAnalysisCompatible(left, right QAFinding) bool {
|
||||
if !qaClusterCompatible(left, right) {
|
||||
return false
|
||||
}
|
||||
|
||||
leftCategory := firstNonEmpty(left.Category, left.Code, left.RuleID)
|
||||
rightCategory := firstNonEmpty(right.Category, right.Code, right.RuleID)
|
||||
if leftCategory != "" && rightCategory != "" && leftCategory != rightCategory {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func qaAnalysisFrequencies(findings []QAFinding) map[string]float64 {
|
||||
frequencies := make(map[string]float64, len(findings))
|
||||
for _, finding := range findings {
|
||||
frequencies[findingFingerprint(finding)]++
|
||||
}
|
||||
return frequencies
|
||||
}
|
||||
|
||||
func qaAnalysisSummary(base map[string]any, report DispatchReport) map[string]any {
|
||||
summary := maps.Clone(base)
|
||||
if summary == nil {
|
||||
summary = map[string]any{}
|
||||
}
|
||||
summary["clusters"] = len(report.Clusters)
|
||||
summary["new"] = len(report.New)
|
||||
summary["resolved"] = len(report.Resolved)
|
||||
summary["persistent"] = len(report.Persistent)
|
||||
return summary
|
||||
}
|
||||
|
||||
func qaAnalysisSummaryText(report DispatchReport) string {
|
||||
return core.Sprintf(
|
||||
"%d findings across %d clusters; %d new, %d resolved, %d persistent",
|
||||
len(report.Findings),
|
||||
len(report.Clusters),
|
||||
len(report.New),
|
||||
len(report.Resolved),
|
||||
len(report.Persistent),
|
||||
)
|
||||
}
|
||||
|
||||
func qaAnalysisSeverityScore(severity string) float64 {
|
||||
switch core.Lower(core.Trim(severity)) {
|
||||
case "critical", "error", "high":
|
||||
return 1
|
||||
case "warning", "warn", "medium":
|
||||
return 0.6
|
||||
case "info", "low":
|
||||
return 0.3
|
||||
default:
|
||||
return 0.1
|
||||
}
|
||||
}
|
||||
|
||||
func qaAnalysisHash(value string) float64 {
|
||||
if core.Trim(value) == "" {
|
||||
return 0
|
||||
}
|
||||
hash := fnv.New32a()
|
||||
_, _ = hash.Write([]byte(value))
|
||||
return float64(hash.Sum32())
|
||||
}
|
||||
137
pkg/agentic/qa_analysis_test.go
Normal file
137
pkg/agentic/qa_analysis_test.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
package agentic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAnalyseWorkspace_Good_EmptyFindings(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
setTestWorkspace(t, root)
|
||||
|
||||
subsystem := newPrepWithProcess()
|
||||
t.Cleanup(subsystem.closeStateStore)
|
||||
|
||||
workspaceDir := core.JoinPath(WorkspaceRoot(), "core", "go-io", "task-empty")
|
||||
workspaceName := WorkspaceName(workspaceDir)
|
||||
workspace, err := subsystem.stateStoreInstance().NewWorkspace(qaWorkspaceName(workspaceDir))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(workspace.Discard)
|
||||
|
||||
report := subsystem.analyseWorkspaceNamed(workspace, workspaceName)
|
||||
|
||||
assert.Equal(t, workspaceName, report.Workspace)
|
||||
assert.Empty(t, report.Findings)
|
||||
assert.Empty(t, report.Clusters)
|
||||
assert.Empty(t, report.New)
|
||||
assert.Empty(t, report.Resolved)
|
||||
assert.Empty(t, report.Persistent)
|
||||
assert.Equal(t, 0, report.Summary["clusters"])
|
||||
assert.Equal(t, "0 findings across 0 clusters; 0 new, 0 resolved, 0 persistent", report.SummaryText)
|
||||
}
|
||||
|
||||
func TestAnalyseWorkspace_Good_FiveClusters(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
setTestWorkspace(t, root)
|
||||
|
||||
subsystem := newPrepWithProcess()
|
||||
t.Cleanup(subsystem.closeStateStore)
|
||||
|
||||
workspaceDir := core.JoinPath(WorkspaceRoot(), "core", "go-io", "task-five")
|
||||
workspaceName := WorkspaceName(workspaceDir)
|
||||
workspace, err := subsystem.stateStoreInstance().NewWorkspace(qaWorkspaceName(workspaceDir))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(workspace.Discard)
|
||||
|
||||
repeated := QAFinding{Tool: "gosec", Severity: "error", Category: "security-secret", Code: "G101", File: "secret.go", Line: 10, Message: "hardcoded secret"}
|
||||
for cycle := 0; cycle < persistentThreshold-1; cycle++ {
|
||||
publishDispatchReport(subsystem.stateStoreInstance(), workspaceName, DispatchReport{
|
||||
Workspace: workspaceName,
|
||||
Findings: []QAFinding{repeated},
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
currentFindings := []QAFinding{
|
||||
repeated,
|
||||
{Tool: "gosec", Severity: "error", Category: "security-path", Code: "G304", File: "path.go", Line: 20, Message: "tainted path"},
|
||||
{Tool: "staticcheck", Severity: "warning", Category: "correctness-regexp", Code: "SA1000", File: "regexp.go", Line: 30, Message: "invalid regexp"},
|
||||
{Tool: "govet", Severity: "warning", Category: "printf", Code: "printf", File: "printf.go", Line: 40, Message: "printf mismatch"},
|
||||
{Tool: "revive", Severity: "info", Category: "var-naming", Code: "var-naming", File: "style.go", Line: 50, Message: "bad variable name"},
|
||||
}
|
||||
for _, finding := range currentFindings {
|
||||
require.NoError(t, workspace.Put("finding", findingToMap(finding)))
|
||||
}
|
||||
|
||||
report := subsystem.analyseWorkspaceNamed(workspace, workspaceName)
|
||||
|
||||
if assert.Len(t, report.Clusters, 5) {
|
||||
for _, cluster := range report.Clusters {
|
||||
assert.Equal(t, 1, cluster.Count)
|
||||
}
|
||||
}
|
||||
assert.Len(t, report.New, 4)
|
||||
assert.Empty(t, report.Resolved)
|
||||
assert.Len(t, report.Persistent, 1)
|
||||
assert.Equal(t, 5, report.Summary["clusters"])
|
||||
assert.Equal(t, 1, report.Summary["persistent"])
|
||||
}
|
||||
|
||||
func TestAnalyseWorkspace_Bad_NilWorkspace(t *testing.T) {
|
||||
var subsystem *PrepSubsystem
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
report := subsystem.analyseWorkspace(nil)
|
||||
assert.Empty(t, report.Workspace)
|
||||
assert.Empty(t, report.Findings)
|
||||
assert.Empty(t, report.Clusters)
|
||||
assert.Empty(t, report.New)
|
||||
assert.Empty(t, report.Resolved)
|
||||
assert.Empty(t, report.Persistent)
|
||||
assert.Equal(t, "0 findings across 0 clusters; 0 new, 0 resolved, 0 persistent", report.SummaryText)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnalyseWorkspace_Ugly_PoindexterPanic(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
setTestWorkspace(t, root)
|
||||
|
||||
subsystem := newPrepWithProcess()
|
||||
t.Cleanup(subsystem.closeStateStore)
|
||||
|
||||
workspaceDir := core.JoinPath(WorkspaceRoot(), "core", "go-io", "task-panic")
|
||||
workspaceName := WorkspaceName(workspaceDir)
|
||||
workspace, err := subsystem.stateStoreInstance().NewWorkspace(qaWorkspaceName(workspaceDir))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(workspace.Discard)
|
||||
|
||||
require.NoError(t, workspace.Put("finding", findingToMap(QAFinding{
|
||||
Tool: "gosec",
|
||||
Severity: "error",
|
||||
Category: "security-secret",
|
||||
Code: "G101",
|
||||
File: "panic.go",
|
||||
Line: 10,
|
||||
Message: "hardcoded secret",
|
||||
})))
|
||||
|
||||
previousClusterer := qaAnalysisClusterer
|
||||
qaAnalysisClusterer = func([]QAFinding) []DispatchCluster {
|
||||
panic("poindexter panic")
|
||||
}
|
||||
t.Cleanup(func() { qaAnalysisClusterer = previousClusterer })
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
report := subsystem.analyseWorkspaceNamed(workspace, workspaceName)
|
||||
if assert.Len(t, report.Clusters, 1) {
|
||||
assert.Equal(t, 1, report.Clusters[0].Count)
|
||||
}
|
||||
assert.Equal(t, 1, report.Summary["clusters"])
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue