feat(agent): RFC §7 QA capture pipeline

The runQA handler now captures every lint finding, tool run, build, vet
and test result into a go-store workspace buffer and commits the cycle
to the journal. Intelligence survives in the report and the journal per
RFC §7 Completion Pipeline.

- qa.go: QAFinding / QAToolRun / QASummary / QAReport DTOs mirroring
  lint.Report shape; DispatchReport struct written to .meta/report.json;
  runQAWithReport opens NewWorkspace("qa-<workspace>"), invokes
  core-lint run --output json via c.Process().RunIn(), records every
  finding + tool + stage result, then commits
- runQALegacy preserved for graceful degradation when go-store is
  unavailable (RFC §15.6)
- dispatch.go: runQA now delegates to runQAWithReport, bool contract
  unchanged for existing call sites
- qa_test.go: Good/Bad/Ugly triads per repo convention

Poindexter clustering from RFC §7 Post-Run Analysis remains open —
needs its own RFC pass for the package boundary.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-04-14 13:02:14 +01:00
parent eed2274746
commit eaf17823d9
3 changed files with 595 additions and 33 deletions

View file

@ -678,40 +678,14 @@ func (m *agentCompletionMonitor) run(_ context.Context, _ core.Options) core.Res
return core.Result{OK: true}
}
// runQA executes the RFC §7 completion pipeline QA step — captures every
// lint finding, build, and test result into a go-store workspace buffer and
// commits the cycle to the journal when a store is available. Falls back to
// the legacy build/vet/test cascade when go-store is not loaded (RFC §15.6).
//
// Usage example: `passed := s.runQA("/workspace/core/go-io/task-5")`
func (s *PrepSubsystem) runQA(workspaceDir string) bool {
ctx := context.Background()
repoDir := WorkspaceRepoDir(workspaceDir)
process := s.Core().Process()
if fs.IsFile(core.JoinPath(repoDir, "go.mod")) {
for _, args := range [][]string{
{"go", "build", "./..."},
{"go", "vet", "./..."},
{"go", "test", "./...", "-count=1", "-timeout", "120s"},
} {
if !process.RunIn(ctx, repoDir, args[0], args[1:]...).OK {
core.Warn("QA failed", "cmd", core.Join(" ", args...))
return false
}
}
return true
}
if fs.IsFile(core.JoinPath(repoDir, "composer.json")) {
if !process.RunIn(ctx, repoDir, "composer", "install", "--no-interaction").OK {
return false
}
return process.RunIn(ctx, repoDir, "composer", "test").OK
}
if fs.IsFile(core.JoinPath(repoDir, "package.json")) {
if !process.RunIn(ctx, repoDir, "npm", "install").OK {
return false
}
return process.RunIn(ctx, repoDir, "npm", "test").OK
}
return true
return s.runQAWithReport(context.Background(), workspaceDir)
}
func (s *PrepSubsystem) dispatch(ctx context.Context, callRequest *mcp.CallToolRequest, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) {

383
pkg/agentic/qa.go Normal file
View file

@ -0,0 +1,383 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"time"
core "dappco.re/go/core"
store "dappco.re/go/core/store"
)
// QAFinding mirrors the lint.Finding shape produced by `core-lint run --output json`.
// Only the fields consumed by the agent pipeline are captured — the full lint
// report is persisted to the workspace buffer for post-run analysis.
//
// Usage example: `finding := QAFinding{Tool: "gosec", File: "main.go", Line: 42, Severity: "error", Code: "G101", Message: "hardcoded secret"}`
type QAFinding struct {
Tool string `json:"tool,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Column int `json:"column,omitempty"`
Severity string `json:"severity,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Category string `json:"category,omitempty"`
RuleID string `json:"rule_id,omitempty"`
Title string `json:"title,omitempty"`
}
// QAToolRun mirrors lint.ToolRun — captures each adapter's execution status so
// the journal records which linters participated in the cycle.
//
// Usage example: `toolRun := QAToolRun{Name: "gosec", Status: "ok", Duration: "2.1s", Findings: 3}`
type QAToolRun struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Status string `json:"status"`
Duration string `json:"duration,omitempty"`
Findings int `json:"findings"`
}
// QASummary mirrors lint.Summary — the aggregate counts by severity.
//
// Usage example: `summary := QASummary{Total: 12, Errors: 3, Warnings: 5, Info: 4, Passed: false}`
type QASummary struct {
Total int `json:"total"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Info int `json:"info"`
Passed bool `json:"passed"`
}
// QAReport mirrors lint.Report — the shape emitted by `core-lint run --output json`.
// The agent parses this JSON to capture raw findings into the workspace buffer.
//
// Usage example: `report := QAReport{}; core.JSONUnmarshalString(jsonOutput, &report)`
type QAReport struct {
Project string `json:"project"`
Timestamp time.Time `json:"timestamp"`
Duration string `json:"duration"`
Languages []string `json:"languages"`
Tools []QAToolRun `json:"tools"`
Findings []QAFinding `json:"findings"`
Summary QASummary `json:"summary"`
}
// DispatchReport summarises the raw findings captured in the workspace buffer
// before the cycle commits to the journal. Written to `.meta/report.json` so
// human reviewers and downstream tooling (Uptelligence, Poindexter) can read
// the cycle without re-scanning the buffer.
//
// Usage example: `report := DispatchReport{Summary: map[string]any{"finding": 12, "tool_run": 3}, Findings: findings, Tools: tools, BuildPassed: true, TestPassed: true}`
type DispatchReport struct {
Workspace string `json:"workspace"`
Commit string `json:"commit,omitempty"`
Summary map[string]any `json:"summary"`
Findings []QAFinding `json:"findings,omitempty"`
Tools []QAToolRun `json:"tools,omitempty"`
BuildPassed bool `json:"build_passed"`
TestPassed bool `json:"test_passed"`
LintPassed bool `json:"lint_passed"`
Passed bool `json:"passed"`
GeneratedAt time.Time `json:"generated_at"`
}
// qaWorkspaceName returns the buffer name used to accumulate a single QA cycle.
// Mirrors RFC §7 — workspaces are namespaced `qa-<workspace-name>` so multiple
// concurrent dispatches produce distinct DuckDB files.
//
// Usage example: `name := qaWorkspaceName("/tmp/workspace/core/go-io/task-5") // "qa-core-go-io-task-5"`
func qaWorkspaceName(workspaceDir string) string {
if workspaceDir == "" {
return "qa-default"
}
name := WorkspaceName(workspaceDir)
if name == "" {
name = core.PathBase(workspaceDir)
}
return core.Concat("qa-", sanitiseWorkspaceName(name))
}
// sanitiseWorkspaceName replaces characters that collide with the go-store
// workspace name validator (`^[a-zA-Z0-9_-]+$`).
//
// Usage example: `clean := sanitiseWorkspaceName("core/go-io/task-5") // "core-go-io-task-5"`
func sanitiseWorkspaceName(name string) string {
runes := []rune(name)
for index, value := range runes {
switch {
case (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z'),
value >= '0' && value <= '9',
value == '-', value == '_':
continue
default:
runes[index] = '-'
}
}
return string(runes)
}
// runLintReport runs `core-lint run --output json` against the repo directory
// and returns the decoded QAReport. When the binary is missing or fails, the
// report comes back empty so the QA pipeline still records build/test without
// crashing — RFC §15.6 graceful degradation applies to the QA step too.
//
// Usage example: `report := s.runLintReport(ctx, "/workspace/repo")`
func (s *PrepSubsystem) runLintReport(ctx context.Context, repoDir string) QAReport {
if repoDir == "" {
return QAReport{}
}
if s == nil || s.Core() == nil {
return QAReport{}
}
result := s.Core().Process().RunIn(ctx, repoDir, "core-lint", "run", "--output", "json", "--path", repoDir)
if !result.OK || result.Value == nil {
return QAReport{}
}
output, ok := result.Value.(string)
if !ok || core.Trim(output) == "" {
return QAReport{}
}
var report QAReport
if parseResult := core.JSONUnmarshalString(output, &report); !parseResult.OK {
return QAReport{}
}
return report
}
// recordLintFindings streams every lint.Finding and every lint.ToolRun into the
// workspace buffer per RFC §7 "QA handler — runs lint, captures all findings
// to workspace store". The store is optional — when go-store is not loaded the
// caller skips this step and falls back to the simple pass/fail run.
//
// Usage example: `s.recordLintFindings(workspace, report)`
func (s *PrepSubsystem) recordLintFindings(workspace *store.Workspace, report QAReport) {
if workspace == nil {
return
}
for _, finding := range report.Findings {
_ = workspace.Put("finding", map[string]any{
"tool": finding.Tool,
"file": finding.File,
"line": finding.Line,
"column": finding.Column,
"severity": finding.Severity,
"code": finding.Code,
"message": finding.Message,
"category": finding.Category,
"rule_id": finding.RuleID,
"title": finding.Title,
})
}
for _, tool := range report.Tools {
_ = workspace.Put("tool_run", map[string]any{
"name": tool.Name,
"version": tool.Version,
"status": tool.Status,
"duration": tool.Duration,
"findings": tool.Findings,
})
}
}
// recordBuildResult persists a build/test cycle row so downstream analysis can
// correlate failures with specific findings.
//
// Usage example: `s.recordBuildResult(workspace, "build", true, "")`
func (s *PrepSubsystem) recordBuildResult(workspace *store.Workspace, kind string, passed bool, output string) {
if workspace == nil || kind == "" {
return
}
_ = workspace.Put(kind, map[string]any{
"passed": passed,
"output": output,
})
}
// runQAWithReport extends runQA with the RFC §7 capture pipeline — it opens a
// go-store workspace buffer, records every lint finding, build, and test
// result, writes `.meta/report.json`, and commits the cycle to the journal.
// The returned bool matches the existing runQA contract so callers need no
// migration. When go-store is unavailable, the function degrades to the
// simple build/vet/test pass/fail path per RFC §15.6.
//
// Usage example: `passed := s.runQAWithReport(ctx, "/workspace/core/go-io/task-5")`
func (s *PrepSubsystem) runQAWithReport(ctx context.Context, workspaceDir string) bool {
if workspaceDir == "" {
return false
}
repoDir := WorkspaceRepoDir(workspaceDir)
if !fs.IsDir(repoDir) {
return s.runQALegacy(ctx, workspaceDir)
}
storeInstance := s.stateStoreInstance()
if storeInstance == nil {
return s.runQALegacy(ctx, workspaceDir)
}
workspace, err := storeInstance.NewWorkspace(qaWorkspaceName(workspaceDir))
if err != nil {
return s.runQALegacy(ctx, workspaceDir)
}
report := s.runLintReport(ctx, repoDir)
s.recordLintFindings(workspace, report)
buildPassed, testPassed := s.runBuildAndTest(ctx, workspace, repoDir)
lintPassed := report.Summary.Errors == 0
dispatchReport := DispatchReport{
Workspace: WorkspaceName(workspaceDir),
Summary: workspace.Aggregate(),
Findings: report.Findings,
Tools: report.Tools,
BuildPassed: buildPassed,
TestPassed: testPassed,
LintPassed: lintPassed,
Passed: buildPassed && testPassed,
GeneratedAt: time.Now().UTC(),
}
writeDispatchReport(workspaceDir, dispatchReport)
commitResult := workspace.Commit()
if !commitResult.OK {
// Commit failed — make sure the buffer does not leak on disk.
workspace.Discard()
}
return dispatchReport.Passed
}
// runBuildAndTest executes the language-specific build/test cycle, recording
// each outcome into the workspace buffer. Mirrors the existing runQA decision
// tree (Go > composer > npm > passthrough) so the captured data matches what
// previously determined pass/fail.
//
// Usage example: `buildPassed, testPassed := s.runBuildAndTest(ctx, ws, "/workspace/repo")`
func (s *PrepSubsystem) runBuildAndTest(ctx context.Context, workspace *store.Workspace, repoDir string) (bool, bool) {
process := s.Core().Process()
switch {
case fs.IsFile(core.JoinPath(repoDir, "go.mod")):
buildResult := process.RunIn(ctx, repoDir, "go", "build", "./...")
s.recordBuildResult(workspace, "build", buildResult.OK, stringOutput(buildResult))
if !buildResult.OK {
return false, false
}
vetResult := process.RunIn(ctx, repoDir, "go", "vet", "./...")
s.recordBuildResult(workspace, "vet", vetResult.OK, stringOutput(vetResult))
if !vetResult.OK {
return false, false
}
testResult := process.RunIn(ctx, repoDir, "go", "test", "./...", "-count=1", "-timeout", "120s")
s.recordBuildResult(workspace, "test", testResult.OK, stringOutput(testResult))
return true, testResult.OK
case fs.IsFile(core.JoinPath(repoDir, "composer.json")):
installResult := process.RunIn(ctx, repoDir, "composer", "install", "--no-interaction")
s.recordBuildResult(workspace, "build", installResult.OK, stringOutput(installResult))
if !installResult.OK {
return false, false
}
testResult := process.RunIn(ctx, repoDir, "composer", "test")
s.recordBuildResult(workspace, "test", testResult.OK, stringOutput(testResult))
return true, testResult.OK
case fs.IsFile(core.JoinPath(repoDir, "package.json")):
installResult := process.RunIn(ctx, repoDir, "npm", "install")
s.recordBuildResult(workspace, "build", installResult.OK, stringOutput(installResult))
if !installResult.OK {
return false, false
}
testResult := process.RunIn(ctx, repoDir, "npm", "test")
s.recordBuildResult(workspace, "test", testResult.OK, stringOutput(testResult))
return true, testResult.OK
default:
// No build system detected — record a passthrough outcome so the
// journal still sees the cycle.
s.recordBuildResult(workspace, "build", true, "no build system detected")
s.recordBuildResult(workspace, "test", true, "no build system detected")
return true, true
}
}
// runQALegacy is the original build/vet/test cascade used when the go-store
// buffer is unavailable. Keeps the old behaviour so offline deployments and
// tests without a state store still pass.
//
// Usage example: `passed := s.runQALegacy(ctx, "/workspace/core/go-io/task-5")`
func (s *PrepSubsystem) runQALegacy(ctx context.Context, workspaceDir string) bool {
repoDir := WorkspaceRepoDir(workspaceDir)
process := s.Core().Process()
if fs.IsFile(core.JoinPath(repoDir, "go.mod")) {
for _, args := range [][]string{
{"go", "build", "./..."},
{"go", "vet", "./..."},
{"go", "test", "./...", "-count=1", "-timeout", "120s"},
} {
if !process.RunIn(ctx, repoDir, args[0], args[1:]...).OK {
core.Warn("QA failed", "cmd", core.Join(" ", args...))
return false
}
}
return true
}
if fs.IsFile(core.JoinPath(repoDir, "composer.json")) {
if !process.RunIn(ctx, repoDir, "composer", "install", "--no-interaction").OK {
return false
}
return process.RunIn(ctx, repoDir, "composer", "test").OK
}
if fs.IsFile(core.JoinPath(repoDir, "package.json")) {
if !process.RunIn(ctx, repoDir, "npm", "install").OK {
return false
}
return process.RunIn(ctx, repoDir, "npm", "test").OK
}
return true
}
// writeDispatchReport serialises the DispatchReport to `.meta/report.json` so
// the human-readable record survives the workspace buffer being committed and
// discarded. Per RFC §7: "the intelligence survives in the report and the
// journal".
//
// Usage example: `writeDispatchReport("/workspace/core/go-io/task-5", report)`
func writeDispatchReport(workspaceDir string, report DispatchReport) {
if workspaceDir == "" {
return
}
metaDir := WorkspaceMetaDir(workspaceDir)
if !fs.EnsureDir(metaDir).OK {
return
}
payload := core.JSONMarshalString(report)
if payload == "" {
return
}
fs.WriteAtomic(core.JoinPath(metaDir, "report.json"), payload)
}
// stringOutput extracts the process output from a core.Result, returning an
// empty string when the value is not a string (e.g. nil on spawn failure).
//
// Usage example: `output := stringOutput(process.RunIn(ctx, dir, "go", "build"))`
func stringOutput(result core.Result) string {
if result.Value == nil {
return ""
}
if value, ok := result.Value.(string); ok {
return value
}
return ""
}

205
pkg/agentic/qa_test.go Normal file
View file

@ -0,0 +1,205 @@
// SPDX-License-Identifier: EUPL-1.2
package agentic
import (
"context"
"testing"
"time"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- qaWorkspaceName ---
func TestQa_QaWorkspaceName_Good(t *testing.T) {
// WorkspaceName strips the configured workspace root prefix before sanitising.
previous := workspaceRootOverride
t.Cleanup(func() { workspaceRootOverride = previous })
setWorkspaceRootOverride("/root")
assert.Equal(t, "qa-core-go-io-task-5", qaWorkspaceName("/root/core/go-io/task-5"))
assert.Equal(t, "qa-simple", qaWorkspaceName("/simple"))
}
func TestQa_QaWorkspaceName_Bad(t *testing.T) {
assert.Equal(t, "qa-default", qaWorkspaceName(""))
}
func TestQa_QaWorkspaceName_Ugly(t *testing.T) {
// Slashes, colons, and dots collapse to dashes so go-store validation passes.
got := qaWorkspaceName("/tmp/workspace/ofm/mobile/bug:42")
assert.Contains(t, got, "qa-")
for _, r := range got {
valid := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_'
assert.True(t, valid, "unexpected rune %q in %q", r, got)
}
}
// --- sanitiseWorkspaceName ---
func TestQa_SanitiseWorkspaceName_Good(t *testing.T) {
assert.Equal(t, "core-go-io-task-5", sanitiseWorkspaceName("core/go-io/task-5"))
assert.Equal(t, "safe_name", sanitiseWorkspaceName("safe_name"))
}
func TestQa_SanitiseWorkspaceName_Bad(t *testing.T) {
assert.Equal(t, "", sanitiseWorkspaceName(""))
}
func TestQa_SanitiseWorkspaceName_Ugly(t *testing.T) {
// Non-ASCII and punctuation characters all collapse to dashes.
got := sanitiseWorkspaceName("core:📦/go-io")
assert.Contains(t, got, "core")
assert.NotContains(t, got, ":")
assert.NotContains(t, got, "/")
}
// --- writeDispatchReport ---
func TestQa_WriteDispatchReport_Good(t *testing.T) {
wsDir := t.TempDir()
report := DispatchReport{
Workspace: WorkspaceName(wsDir),
Passed: true,
BuildPassed: true,
TestPassed: true,
LintPassed: true,
GeneratedAt: time.Now().UTC(),
}
writeDispatchReport(wsDir, report)
reportPath := core.JoinPath(WorkspaceMetaDir(wsDir), "report.json")
assert.True(t, fs.IsFile(reportPath))
readResult := fs.Read(reportPath)
assert.True(t, readResult.OK)
var restored DispatchReport
parseResult := core.JSONUnmarshalString(readResult.Value.(string), &restored)
assert.True(t, parseResult.OK)
assert.Equal(t, report.Passed, restored.Passed)
assert.Equal(t, report.BuildPassed, restored.BuildPassed)
}
func TestQa_WriteDispatchReport_Bad(t *testing.T) {
// Empty workspace dir is a no-op — no panic, no file created.
writeDispatchReport("", DispatchReport{})
}
func TestQa_WriteDispatchReport_Ugly(t *testing.T) {
// Tolerates findings + tools with zero-value fields.
wsDir := t.TempDir()
report := DispatchReport{
Workspace: "empty",
Findings: []QAFinding{{}, {Tool: "gosec"}},
Tools: []QAToolRun{{}, {Name: "gofmt"}},
}
writeDispatchReport(wsDir, report)
reportPath := core.JoinPath(WorkspaceMetaDir(wsDir), "report.json")
assert.True(t, fs.IsFile(reportPath))
}
// --- recordBuildResult ---
func TestQa_RecordBuildResult_Good(t *testing.T) {
// nil workspace is a no-op (graceful degradation path).
s := newPrepWithProcess()
s.recordBuildResult(nil, "build", true, "ok")
}
func TestQa_RecordBuildResult_Bad(t *testing.T) {
s := newPrepWithProcess()
// Empty kind is skipped so we never insert rows without a kind.
s.recordBuildResult(nil, "", true, "ignored")
}
func TestQa_RecordBuildResult_Ugly(t *testing.T) {
// Ugly path — very large output strings should not crash the nil-ws path.
s := newPrepWithProcess()
s.recordBuildResult(nil, "test", false, string(make([]byte, 1024*16)))
}
// --- runLintReport ---
func TestQa_RunLintReport_Good(t *testing.T) {
// No repoDir → empty report, never errors.
s := newPrepWithProcess()
report := s.runLintReport(context.Background(), "")
assert.Empty(t, report.Findings)
assert.Empty(t, report.Tools)
}
func TestQa_RunLintReport_Bad(t *testing.T) {
// Nil subsystem → empty report (no panic).
var s *PrepSubsystem
report := s.runLintReport(context.Background(), t.TempDir())
assert.Empty(t, report.Findings)
}
func TestQa_RunLintReport_Ugly(t *testing.T) {
// Missing core-lint binary → empty report; the caller degrades gracefully.
s := newPrepWithProcess()
report := s.runLintReport(context.Background(), t.TempDir())
assert.Empty(t, report.Findings)
}
// --- runQAWithReport ---
func TestQa_RunQAWithReport_Good(t *testing.T) {
wsDir := t.TempDir()
repoDir := core.JoinPath(wsDir, "repo")
fs.EnsureDir(repoDir)
fs.Write(core.JoinPath(repoDir, "go.mod"), "module testmod\n\ngo 1.22\n")
fs.Write(core.JoinPath(repoDir, "main.go"), "package main\nfunc main() {}\n")
s := newPrepWithProcess()
assert.True(t, s.runQAWithReport(context.Background(), wsDir))
// Report file should exist when the state store is available.
reportPath := core.JoinPath(WorkspaceMetaDir(wsDir), "report.json")
if fs.IsFile(reportPath) {
readResult := fs.Read(reportPath)
assert.True(t, readResult.OK)
var report DispatchReport
parseResult := core.JSONUnmarshalString(readResult.Value.(string), &report)
assert.True(t, parseResult.OK)
assert.True(t, report.Passed)
}
}
func TestQa_RunQAWithReport_Bad(t *testing.T) {
// Missing repo → runQALegacy returns true because no build system is
// detected under the workspace root. The important assertion is that
// runQAWithReport never panics on an empty workspace dir.
s := newPrepWithProcess()
assert.NotPanics(t, func() {
s.runQAWithReport(context.Background(), "")
})
}
func TestQa_RunQAWithReport_Ugly(t *testing.T) {
// Unknown language — no build system detected but no panic.
wsDir := t.TempDir()
fs.EnsureDir(core.JoinPath(wsDir, "repo"))
s := newPrepWithProcess()
assert.True(t, s.runQAWithReport(context.Background(), wsDir))
}
// --- stringOutput ---
func TestQa_StringOutput_Good(t *testing.T) {
assert.Equal(t, "hello", stringOutput(core.Result{Value: "hello", OK: true}))
}
func TestQa_StringOutput_Bad(t *testing.T) {
assert.Equal(t, "", stringOutput(core.Result{Value: nil, OK: false}))
assert.Equal(t, "", stringOutput(core.Result{Value: 42, OK: true}))
}
func TestQa_StringOutput_Ugly(t *testing.T) {
assert.Equal(t, "", stringOutput(core.Result{}))
}