diff --git a/internal/cli/run.go b/internal/cli/run.go index 52dfe39..0e710fb 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -6,9 +6,11 @@ import ( "fmt" "io" "strings" + "time" "gitea.maximumdirect.net/eric/audita/internal/core/config" coreio "gitea.maximumdirect.net/eric/audita/internal/core/io" + "gitea.maximumdirect.net/eric/audita/internal/core/reporting" ) type processInvocation struct { @@ -66,6 +68,8 @@ func Run(args []string, stdout, stderr io.Writer) int { } func runProcess(args []string, stdout, stderr io.Writer) int { + startedAt := time.Now().UTC() + cfg, err := config.LoadFromEnv() if err != nil { fmt.Fprintf(stderr, "audita process: invalid environment configuration: %v\n", err) @@ -182,13 +186,47 @@ func runProcess(args []string, stdout, stderr io.Writer) int { } if err := processRunner(inv, stdout); err != nil { + completedAt := time.Now().UTC() + if strings.TrimSpace(inv.ReportJSONPath) != "" { + report := buildProcessReport("failed", inv, startedAt, completedAt, err.Error()) + if reportErr := reporting.WriteProcessReport(inv.ReportJSONPath, report); reportErr != nil { + fmt.Fprintf(stderr, "audita process: %v\n", reportErr) + } + } fmt.Fprintf(stderr, "audita process: %v\n", err) return 1 } + if strings.TrimSpace(inv.ReportJSONPath) != "" { + completedAt := time.Now().UTC() + report := buildProcessReport("success", inv, startedAt, completedAt, "") + if err := reporting.WriteProcessReport(inv.ReportJSONPath, report); err != nil { + fmt.Fprintf(stderr, "audita process: %v\n", err) + return 1 + } + } + return 0 } +func buildProcessReport(status string, inv processInvocation, startedAt, completedAt time.Time, errorMessage string) reporting.ProcessReport { + report := reporting.ProcessReport{ + Phase: "phase1-minimal", + Status: status, + Operation: "process", + TranscriptPath: inv.TranscriptPath, + GlossaryPath: inv.GlossaryPath, + OutputPath: inv.OutputPath, + Modules: append([]string(nil), inv.Config.Modules...), + StartedAt: startedAt, + CompletedAt: &completedAt, + } + if errorMessage != "" { + report.ErrorMessage = errorMessage + } + return report +} + type processFlags struct { glossaryPath *string outputPath *string diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 77c7689..57aff57 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -204,6 +204,143 @@ func TestRunProcessOutputToStdout(t *testing.T) { assertJSONSemanticallyEqual(t, inputBytes, stdout.Bytes()) } +func TestRunProcessReportJSONSuccess(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + transcriptPath := fixturePath("tiny_transcript.json") + glossaryPath := fixturePath("tiny_glossary.yaml") + outputPath := filepath.Join(t.TempDir(), "corrected.json") + reportPath := filepath.Join(t.TempDir(), "report.json") + + exitCode := Run([]string{ + "process", + transcriptPath, + "--glossary", + glossaryPath, + "--output", + outputPath, + "--report-json", + reportPath, + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout when --output is used, got %q", stdout.String()) + } + + report := readProcessReport(t, reportPath) + if report.Status != "success" { + t.Fatalf("expected success report status, got %q", report.Status) + } + if report.Operation != "process" { + t.Fatalf("expected operation process, got %q", report.Operation) + } + if report.TranscriptPath != transcriptPath { + t.Fatalf("unexpected transcript path in report: %q", report.TranscriptPath) + } + if report.GlossaryPath != glossaryPath { + t.Fatalf("unexpected glossary path in report: %q", report.GlossaryPath) + } + if report.OutputPath != outputPath { + t.Fatalf("unexpected output path in report: %q", report.OutputPath) + } + if len(report.Modules) == 0 { + t.Fatalf("expected configured module sequence in report") + } + if report.StartedAt == "" || report.CompletedAt == "" { + t.Fatalf("expected started_at and completed_at timestamps in report") + } + if report.ErrorMessage != "" { + t.Fatalf("did not expect error message in success report, got %q", report.ErrorMessage) + } +} + +func TestRunProcessReportJSONNoStdoutPollution(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + transcriptPath := fixturePath("tiny_transcript.json") + glossaryPath := fixturePath("tiny_glossary.yaml") + reportPath := filepath.Join(t.TempDir(), "report.json") + + exitCode := Run([]string{ + "process", + transcriptPath, + "--glossary", + glossaryPath, + "--report-json", + reportPath, + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + } + assertJSONSemanticallyEqual(t, readFile(t, transcriptPath), stdout.Bytes()) + + reportBytes := readFile(t, reportPath) + if bytes.Contains(stdout.Bytes(), reportBytes) { + t.Fatalf("stdout was polluted with report JSON") + } +} + +func TestRunProcessReportJSONRedactsSecrets(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + secretPrimary := "top-secret-primary-key" + secretValidation := "top-secret-validation-key" + t.Setenv("AUDITA_LLM_API_KEY", secretPrimary) + t.Setenv("AUDITA_VALIDATION_LLM_API_KEY", secretValidation) + + reportPath := filepath.Join(t.TempDir(), "report.json") + exitCode := Run([]string{ + "process", + fixturePath("tiny_transcript.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--report-json", + reportPath, + }, &stdout, &stderr) + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String()) + } + + reportBytes := readFile(t, reportPath) + if strings.Contains(string(reportBytes), secretPrimary) || strings.Contains(string(reportBytes), secretValidation) { + t.Fatalf("report JSON leaked API key material: %q", string(reportBytes)) + } +} + +func TestRunProcessReportJSONBestEffortOnFailure(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + reportPath := filepath.Join(t.TempDir(), "report.json") + exitCode := Run([]string{ + "process", + fixturePath("malformed_transcript.json"), + "--glossary", + fixturePath("tiny_glossary.yaml"), + "--report-json", + reportPath, + }, &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("expected nonzero exit code for malformed transcript") + } + + report := readProcessReport(t, reportPath) + if report.Status != "failed" { + t.Fatalf("expected failed report status, got %q", report.Status) + } + if report.ErrorMessage == "" { + t.Fatalf("expected error message in failed report") + } + if !strings.Contains(report.ErrorMessage, "not valid JSON") { + t.Fatalf("expected malformed JSON failure in report error, got %q", report.ErrorMessage) + } +} + func TestRunProcessCLIOverridesEnvironment(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -281,3 +418,25 @@ func assertJSONSemanticallyEqual(t *testing.T, expected []byte, actual []byte) { t.Fatalf("JSON content mismatch: expected %q got %q", string(expected), string(actual)) } } + +type minimalProcessReport struct { + Status string `json:"status"` + Operation string `json:"operation"` + TranscriptPath string `json:"transcript_path"` + GlossaryPath string `json:"glossary_path"` + OutputPath string `json:"output_path"` + Modules []string `json:"modules"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at"` + ErrorMessage string `json:"error_message"` +} + +func readProcessReport(t *testing.T, path string) minimalProcessReport { + t.Helper() + raw := readFile(t, path) + var report minimalProcessReport + if err := json.Unmarshal(raw, &report); err != nil { + t.Fatalf("failed to unmarshal process report JSON: %v (raw: %q)", err, string(raw)) + } + return report +} diff --git a/internal/core/reporting/report.go b/internal/core/reporting/report.go new file mode 100644 index 0000000..2b023aa --- /dev/null +++ b/internal/core/reporting/report.go @@ -0,0 +1,34 @@ +package reporting + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +type ProcessReport struct { + Phase string `json:"phase"` + Status string `json:"status"` + Operation string `json:"operation"` + TranscriptPath string `json:"transcript_path"` + GlossaryPath string `json:"glossary_path"` + OutputPath string `json:"output_path,omitempty"` + Modules []string `json:"modules"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +func WriteProcessReport(path string, report ProcessReport) error { + payload, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal process report JSON: %w", err) + } + payload = append(payload, '\n') + + if err := os.WriteFile(path, payload, 0o644); err != nil { + return fmt.Errorf("failed to write report JSON file %q: %w", path, err) + } + return nil +}