diff --git a/docs/cli.md b/docs/cli.md index d96248e4..a9a67ebf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -103,8 +103,9 @@ names, requiredness, and configured bindings are part of the Without **--json**, standard output contains the completed pipeline ID, counts of normalized and rejected outputs, and the output directory. A debug-enabled run also prints its debug-bundle path to standard output. A successful run with -actionable process warnings report their group and occurrence counts plus the -durable warning-file path to standard error. Advisory and observation findings +actionable process warnings reports their group and occurrence counts to +standard error. When the selected output module publishes `warnings.json`, the +summary also reports that durable file's path. Advisory and observation findings do not produce a warning line. The published JSON bundle is defined by the [JSON output contract](integrations/json-output.md). diff --git a/internal/cli/assembled_spell_pipeline_contract_test.go b/internal/cli/assembled_spell_pipeline_contract_test.go index 72691804..95ffe103 100644 --- a/internal/cli/assembled_spell_pipeline_contract_test.go +++ b/internal/cli/assembled_spell_pipeline_contract_test.go @@ -71,17 +71,17 @@ func TestAssembledSpellPipelineNormalizesMergedCasts(t *testing.T) { t.Fatalf("distinct cast = %#v, want separate evidence event", distinct) } - wantWarningReasons := []string{ + wantDiagnosticReasons := []string{ spellnormalize.ReasonCodeSpellNameCanonicalized, spellnormalize.ReasonCodeSourceReferencesNormalized, spellnormalize.ReasonCodeDuplicateSpellCastCollapsed, "spell_not_near_source", } - gotWarningReasons := make([]string, len(output.Diagnostics.Groups)) + gotDiagnosticReasons := make([]string, len(output.Diagnostics.Groups)) for index, group := range output.Diagnostics.Groups { - gotWarningReasons[index] = group.ReasonCode + gotDiagnosticReasons[index] = group.ReasonCode } - if !reflect.DeepEqual(gotWarningReasons, wantWarningReasons) { + if !reflect.DeepEqual(gotDiagnosticReasons, wantDiagnosticReasons) { t.Fatalf("diagnostics = %#v, want deterministic normalize and validation diagnostics", output.Diagnostics) } if output.Diagnostics.Groups[2].Samples[0].Scope != "spell_casts[0]" || !strings.Contains(output.Diagnostics.Groups[2].Samples[0].Message, "retained input index 0") || !strings.Contains(output.Diagnostics.Groups[2].Samples[0].Message, "removed input indices [1]") { @@ -173,7 +173,7 @@ func TestAssembledSpellPipelineHonorsNormalizeValidatorOverride(t *testing.T) { } } -func TestAssembledSpellPipelinePromotesTerminalUnknownSpellWarning(t *testing.T) { +func TestAssembledSpellPipelinePromotesTerminalUnknownSpellDiagnostics(t *testing.T) { registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{unknownSpell: true}) resolved.Steps[0].ArtifactLanes[0].NormalizeValidationPolicy.SemanticRejection = pipeline.SemanticRejectionRejectOutput prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{}) @@ -206,7 +206,7 @@ func TestAssembledSpellPipelinePromotesTerminalUnknownSpellWarning(t *testing.T) } } -func TestAssembledSpellPipelinePromotesUnknownSpellWarningWhenOverrideAccepts(t *testing.T) { +func TestAssembledSpellPipelinePromotesUnknownSpellAdvisoryWhenOverrideAccepts(t *testing.T) { registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{normalizeValidatorOverride: true, unknownSpell: true}) prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{}) if err != nil { diff --git a/internal/cli/run.go b/internal/cli/run.go index cc020993..8b87889e 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -471,7 +471,10 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i Debug: debugRecorder, ExtractWorkers: cfg.Concurrency.StageWorkers["extract"], }) - commandState.observeOutput(output) + diagnosticProjection, diagnosticErr := contracts.ProjectDiagnosticCollection(output.Diagnostics) + if diagnosticErr == nil { + commandState.observeOutput(output, diagnosticProjection) + } if err != nil { primaryErr := fmt.Errorf("run pipeline %q: %w", pipelineID, err) if output.Manifest.PipelineID != "" { @@ -479,15 +482,21 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i return failPipelineCommand(stderr, commandState, terminalWriter, primaryErr, fmt.Errorf("write debug summary: %w", summaryErr)) } } + if diagnosticErr != nil { + return failPipelineCommand(stderr, commandState, terminalWriter, primaryErr, fmt.Errorf("summarize run diagnostics: %w", diagnosticErr)) + } return failPipelineCommand(stderr, commandState, terminalWriter, primaryErr) } + if diagnosticErr != nil { + return failPipelineCommand(stderr, commandState, terminalWriter, fmt.Errorf("summarize run diagnostics: %w", diagnosticErr)) + } if err := writePartialSummary(summary, output); err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, fmt.Errorf("write debug summary: %w", err)) } var encodedResult []byte if *machineOutput { - result, err := newRunResult(effective.ResolvedPipeline, output, runOutputDir, debugPath) + result, err := newRunResultWithDiagnostics(effective.ResolvedPipeline, output, runOutputDir, debugPath, diagnosticProjection) if err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, err) } @@ -513,12 +522,25 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i fmt.Fprintf(stdout, "debug=%s\n", debugPath) } } - if warningGroups := warningGroupCount(output.Diagnostics); warningGroups > 0 { - fmt.Fprintf(stderr, "notarius: run completed with %d warning group(s), %d occurrence(s); details=%s\n", warningGroups, warningOccurrenceCount(output.Diagnostics), filepath.Join(runOutputDir, "warnings.json")) + if warningGroups := len(diagnosticProjection.Warnings); warningGroups > 0 { + fmt.Fprintf(stderr, "notarius: run completed with %d warning group(s), %d occurrence(s)", warningGroups, diagnosticProjection.WarningOccurrenceCount) + if warningFile, ok := logicalOutputFile(output.OutputFiles, "warnings.json"); ok { + fmt.Fprintf(stderr, "; details=%s", filepath.Join(runOutputDir, warningFile)) + } + fmt.Fprintln(stderr) } return 0 } +func logicalOutputFile(files []contracts.OutputFile, name string) (string, bool) { + for _, file := range files { + if file.Name == name { + return file.Name, true + } + } + return "", false +} + func writeSummary(summary *debugbundle.SummaryWriter, write func() error) error { if summary == nil { return nil diff --git a/internal/cli/run_contract_test.go b/internal/cli/run_contract_test.go index 388d4b08..0586e442 100644 --- a/internal/cli/run_contract_test.go +++ b/internal/cli/run_contract_test.go @@ -590,6 +590,7 @@ func TestRunWarningsRemainSuccessfulAndReachDurableSurfaces(t *testing.T) { roots := newStateTestRoots(t) harness := newStateTestHarness() harness.includeWarnings = true + harness.includeWarningFile = true harness.chunkDiagnostics = []contracts.ProducerDiagnostic{stateTestDiagnostic("chunk", "contract-warning", "warning retained")} var stdout, stderr bytes.Buffer code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}, &stdout, &stderr, harness.options()) @@ -601,6 +602,7 @@ func TestRunWarningsRemainSuccessfulAndReachDurableSurfaces(t *testing.T) { if err != nil || !strings.Contains(string(output), "contract-warning") { t.Fatalf("durable output = %q, %v", output, err) } + assertFile(t, filepath.Join(filepath.Dir(outputPath), "warnings.json")) bundle := onlyChildDir(t, roots.debug) var diagnostics contracts.DiagnosticCollection readStateTestSummaryJSON(t, bundle, "final-diagnostics.json", &diagnostics) diff --git a/internal/cli/run_result.go b/internal/cli/run_result.go index 4e4d1e9b..b421d7e0 100644 --- a/internal/cli/run_result.go +++ b/internal/cli/run_result.go @@ -33,6 +33,14 @@ type runResult struct { } func newRunResult(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput, outputDirectory, debugDirectory string) (runResult, error) { + diagnosticProjection, err := contracts.ProjectDiagnosticCollection(output.Diagnostics) + if err != nil { + return runResult{}, fmt.Errorf("summarize run diagnostics: %w", err) + } + return newRunResultWithDiagnostics(resolved, output, outputDirectory, debugDirectory, diagnosticProjection) +} + +func newRunResultWithDiagnostics(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput, outputDirectory, debugDirectory string, diagnosticProjection contracts.DiagnosticProjection) (runResult, error) { if strings.TrimSpace(output.Manifest.RunID) == "" { return runResult{}, fmt.Errorf("run result requires a run ID") } @@ -64,10 +72,10 @@ func newRunResult(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput, OutputDirectory: absOutputDirectory, NormalizedOutputCount: len(output.NormalizeOutputs), RejectedOutputCount: len(output.Rejected), - WarningGroupCount: warningGroupCount(output.Diagnostics), - WarningOccurrenceCount: warningOccurrenceCount(output.Diagnostics), - DiagnosticGroupCount: diagnosticGroupCount(output.Diagnostics), - DiagnosticOccurrenceCount: diagnosticOccurrenceCount(output.Diagnostics), + WarningGroupCount: len(diagnosticProjection.Warnings), + WarningOccurrenceCount: diagnosticProjection.WarningOccurrenceCount, + DiagnosticGroupCount: len(diagnosticProjection.Diagnostics), + DiagnosticOccurrenceCount: diagnosticProjection.DiagnosticOccurrenceCount, DiagnosticsTruncated: output.Diagnostics.Truncated, ValidationStatus: output.Manifest.ValidationStatus, ValidationSummaries: cloneValidationSummaries(output.Manifest.ValidationSummaries), @@ -97,44 +105,6 @@ func newRunResult(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput, return result, nil } -func warningGroupCount(diagnostics contracts.DiagnosticCollection) int { - count := 0 - for _, group := range diagnostics.Groups { - if group.Disposition == contracts.DiagnosticDispositionWarning { - count++ - } - } - return count -} - -func warningOccurrenceCount(diagnostics contracts.DiagnosticCollection) int { - return occurrenceCountByDisposition(diagnostics, contracts.DiagnosticDispositionWarning, true) -} - -func diagnosticGroupCount(diagnostics contracts.DiagnosticCollection) int { - count := 0 - for _, group := range diagnostics.Groups { - if group.Disposition != contracts.DiagnosticDispositionWarning { - count++ - } - } - return count -} - -func diagnosticOccurrenceCount(diagnostics contracts.DiagnosticCollection) int { - return occurrenceCountByDisposition(diagnostics, contracts.DiagnosticDispositionWarning, false) + diagnostics.UnrepresentedOccurrenceCount -} - -func occurrenceCountByDisposition(diagnostics contracts.DiagnosticCollection, disposition contracts.DiagnosticDisposition, include bool) int { - count := 0 - for _, group := range diagnostics.Groups { - if (group.Disposition == disposition) == include { - count += group.OccurrenceCount - } - } - return count -} - func cloneValidationSummaries(summaries []artifacts.ValidationSummary) []artifacts.ValidationSummary { if len(summaries) == 0 { return nil diff --git a/internal/cli/run_result_command_test.go b/internal/cli/run_result_command_test.go index ee79e018..f651ea48 100644 --- a/internal/cli/run_result_command_test.go +++ b/internal/cli/run_result_command_test.go @@ -69,7 +69,7 @@ func TestRunResultReportsWarningsAndDebugBundle(t *testing.T) { "run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug", "--json", }, &stdout, &stderr, harness.options()) - if code != 0 || !strings.Contains(stderr.String(), "1 warning group(s), 1 occurrence(s)") || !strings.Contains(stderr.String(), "warnings.json") { + if code != 0 || !strings.Contains(stderr.String(), "1 warning group(s), 1 occurrence(s)") || strings.Contains(stderr.String(), "warnings.json") { t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } diff --git a/internal/cli/run_result_test.go b/internal/cli/run_result_test.go index cffe4251..dec64dd2 100644 --- a/internal/cli/run_result_test.go +++ b/internal/cli/run_result_test.go @@ -184,7 +184,11 @@ func testRunOutput() pipeline.RunOutput { Rejected: []contracts.RejectedOutput{{}}, Diagnostics: contracts.DiagnosticCollection{Groups: []contracts.DiagnosticGroup{{ Disposition: contracts.DiagnosticDispositionWarning, + Category: contracts.DiagnosticCategoryFallback, + ReasonCode: "fallback", + Origin: contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageNormalize, StepID: "step", LaneID: "lane", ModuleKey: "module"}, OccurrenceCount: 1, + Samples: []contracts.DiagnosticSample{{Scope: "scope", Message: "message"}}, }}}, OutputFiles: []contracts.OutputFile{{Name: "index.json"}}, } diff --git a/internal/cli/run_terminal.go b/internal/cli/run_terminal.go index 084cfe1b..0ccd9c7a 100644 --- a/internal/cli/run_terminal.go +++ b/internal/cli/run_terminal.go @@ -6,6 +6,7 @@ import ( "io" "gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) @@ -33,16 +34,16 @@ func (s *pipelineCommandState) setDebugPath(debugPath string) { } } -func (s *pipelineCommandState) observeOutput(output pipeline.RunOutput) { +func (s *pipelineCommandState) observeOutput(output pipeline.RunOutput, diagnostics contracts.DiagnosticProjection) { if s == nil { return } s.report.OutputCount = len(output.NormalizeOutputs) s.report.RejectedCount = len(output.Rejected) - s.report.WarningGroupCount = warningGroupCount(output.Diagnostics) - s.report.WarningOccurrenceCount = warningOccurrenceCount(output.Diagnostics) - s.report.DiagnosticGroupCount = diagnosticGroupCount(output.Diagnostics) - s.report.DiagnosticOccurrenceCount = diagnosticOccurrenceCount(output.Diagnostics) + s.report.WarningGroupCount = len(diagnostics.Warnings) + s.report.WarningOccurrenceCount = diagnostics.WarningOccurrenceCount + s.report.DiagnosticGroupCount = len(diagnostics.Diagnostics) + s.report.DiagnosticOccurrenceCount = diagnostics.DiagnosticOccurrenceCount s.report.DiagnosticsTruncated = output.Diagnostics.Truncated s.report.ValidationStatus = output.Manifest.ValidationStatus } diff --git a/internal/cli/spell_catalog_retry_contract_test.go b/internal/cli/spell_catalog_retry_contract_test.go index 961a82eb..b88b0f31 100644 --- a/internal/cli/spell_catalog_retry_contract_test.go +++ b/internal/cli/spell_catalog_retry_contract_test.go @@ -19,12 +19,12 @@ import ( func TestProductionSpellCatalogValidationRetries(t *testing.T) { const retries = 2 tests := []struct { - name string - responses []string - wantCalls int - wantRejected bool - wantSpell string - wantWarningCode string + name string + responses []string + wantCalls int + wantRejected bool + wantSpell string + wantAdvisoryCode string }{ { name: "unknown spell remains rejected after exhaustion", @@ -42,9 +42,9 @@ func TestProductionSpellCatalogValidationRetries(t *testing.T) { productionSpellResponse("Unknown Spell"), productionSpellResponse("Aegis of Emberfall"), }, - wantCalls: 2, - wantSpell: "Aegis of Emberfall", - wantWarningCode: "spell_not_near_source", + wantCalls: 2, + wantSpell: "Aegis of Emberfall", + wantAdvisoryCode: "spell_not_near_source", }, } @@ -108,14 +108,14 @@ func TestProductionSpellCatalogValidationRetries(t *testing.T) { if len(value.SpellCasts) != 1 || value.SpellCasts[0].Spell != tt.wantSpell { t.Fatalf("normalized spell list = %#v, want accepted overlay spell", value) } - if len(output.Diagnostics.Groups) != 2 || output.Diagnostics.Groups[0].ReasonCode != tt.wantWarningCode { + if len(output.Diagnostics.Groups) != 2 || output.Diagnostics.Groups[0].ReasonCode != tt.wantAdvisoryCode { t.Fatalf("diagnostics = %#v, want terminal extract and normalize diagnostics", output.Diagnostics) } if len(output.Diagnostics.Groups) != 2 { t.Fatalf("diagnostics = %#v, want extract and normalize data-quality advisories", output.Diagnostics) } for _, diagnostic := range output.Diagnostics.Groups { - if diagnostic.Disposition != contracts.DiagnosticDispositionAdvisory || diagnostic.ReasonCode != tt.wantWarningCode { + if diagnostic.Disposition != contracts.DiagnosticDispositionAdvisory || diagnostic.ReasonCode != tt.wantAdvisoryCode { t.Fatalf("diagnostics = %#v, want only data-quality advisories", output.Diagnostics) } } diff --git a/internal/cli/state_hardening_test.go b/internal/cli/state_hardening_test.go index fcaf7e49..c32e0abf 100644 --- a/internal/cli/state_hardening_test.go +++ b/internal/cli/state_hardening_test.go @@ -868,6 +868,7 @@ type stateTestHarness struct { sessionIDs []string outputDiagnostics contracts.DiagnosticCollection includeWarnings bool + includeWarningFile bool } func newStateTestHarness() *stateTestHarness { return &stateTestHarness{} } @@ -1012,7 +1013,11 @@ func (o stateTestOutput) Encode(_ context.Context, req contracts.OutputRequest) if o.includeDiagnostics && len(req.Diagnostics.Groups) > 0 { data = []byte(fmt.Sprintf("{\"ok\":true,\"diagnostics\":%q}\n", req.Diagnostics.Groups[0].ReasonCode)) } - return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: data}}}, nil + files := []contracts.OutputFile{{Name: "result.json", Bytes: data}} + if o.harness.includeWarningFile { + files = append(files, contracts.OutputFile{Name: "warnings.json", Bytes: []byte("{\"warnings\":true}\n")}) + } + return contracts.OutputResult{Files: files}, nil } func stateTestDiagnostic(scope, reasonCode, message string) contracts.ProducerDiagnostic { diff --git a/internal/framework/contracts/correction.go b/internal/framework/contracts/correction.go index 1880aeb1..5434ac39 100644 --- a/internal/framework/contracts/correction.go +++ b/internal/framework/contracts/correction.go @@ -126,6 +126,9 @@ func (candidate ModelCandidate) Validate() error { } func ValidateValidationResult(result ValidationResult) error { + if err := ValidateProducerDiagnostics(result.Diagnostics); err != nil { + return fmt.Errorf("validation diagnostics: %w", err) + } if !result.Approved && result.ReasonCode == "" { return errors.New("validation rejection reason code must not be empty") } diff --git a/internal/framework/contracts/correction_test.go b/internal/framework/contracts/correction_test.go index 04e856a6..96dcc6c0 100644 --- a/internal/framework/contracts/correction_test.go +++ b/internal/framework/contracts/correction_test.go @@ -75,6 +75,9 @@ func TestCorrectionContractsRejectInvalidContent(t *testing.T) { {"oversized correction guidance", func() error { return ValidateValidationResult(ValidationResult{ReasonCode: "invalid", CorrectionGuidance: tooLongValidationGuidance}) }}, + {"invalid diagnostics", func() error { + return ValidateValidationResult(ValidationResult{Approved: true, Diagnostics: []ProducerDiagnostic{{}}}) + }}, } { t.Run(test.name, func(t *testing.T) { if err := test.call(); err == nil { diff --git a/internal/framework/contracts/diagnostics.go b/internal/framework/contracts/diagnostics.go index 301f18e6..7cb555f6 100644 --- a/internal/framework/contracts/diagnostics.go +++ b/internal/framework/contracts/diagnostics.go @@ -102,6 +102,16 @@ type DiagnosticCollection struct { UnrepresentedOccurrenceCount int `json:"unrepresented_occurrence_count"` } +// DiagnosticProjection is the validated warning/non-warning view used by +// durable and presentation boundaries. Occurrence totals are checked before +// they leave the framework contract. +type DiagnosticProjection struct { + Warnings []DiagnosticGroup + Diagnostics []DiagnosticGroup + WarningOccurrenceCount int + DiagnosticOccurrenceCount int +} + // Validate checks a producer-local diagnostic against the public safety and // classification contract. func (diagnostic ProducerDiagnostic) Validate() error { @@ -192,6 +202,41 @@ func (collection DiagnosticCollection) Validate() error { return nil } +// ProjectDiagnosticCollection validates, partitions, and totals one finalized +// collection. Unrepresented occurrences belong to the non-warning projection. +func ProjectDiagnosticCollection(collection DiagnosticCollection) (DiagnosticProjection, error) { + if err := collection.Validate(); err != nil { + return DiagnosticProjection{}, err + } + projection := DiagnosticProjection{ + Warnings: make([]DiagnosticGroup, 0), + Diagnostics: make([]DiagnosticGroup, 0), + } + for _, group := range collection.Groups { + if group.Disposition == DiagnosticDispositionWarning { + count, err := addDiagnosticOccurrences(projection.WarningOccurrenceCount, group.OccurrenceCount) + if err != nil { + return DiagnosticProjection{}, fmt.Errorf("warning occurrences: %w", err) + } + projection.WarningOccurrenceCount = count + projection.Warnings = append(projection.Warnings, cloneDiagnosticGroup(group)) + continue + } + count, err := addDiagnosticOccurrences(projection.DiagnosticOccurrenceCount, group.OccurrenceCount) + if err != nil { + return DiagnosticProjection{}, fmt.Errorf("diagnostic occurrences: %w", err) + } + projection.DiagnosticOccurrenceCount = count + projection.Diagnostics = append(projection.Diagnostics, cloneDiagnosticGroup(group)) + } + count, err := addDiagnosticOccurrences(projection.DiagnosticOccurrenceCount, collection.UnrepresentedOccurrenceCount) + if err != nil { + return DiagnosticProjection{}, fmt.Errorf("diagnostic occurrences: %w", err) + } + projection.DiagnosticOccurrenceCount = count + return projection, nil +} + // CloneProducerDiagnostics returns independent diagnostic slice ownership. func CloneProducerDiagnostics(diagnostics []ProducerDiagnostic) []ProducerDiagnostic { if len(diagnostics) == 0 { @@ -305,6 +350,13 @@ func cloneDiagnosticGroup(group DiagnosticGroup) DiagnosticGroup { return group } +func addDiagnosticOccurrences(current, incoming int) (int, error) { + if incoming > int(^uint(0)>>1)-current { + return 0, errors.New("occurrence count overflow") + } + return current + incoming, nil +} + func cloneDiagnosticSamples(samples []DiagnosticSample) []DiagnosticSample { if len(samples) == 0 { return nil diff --git a/internal/framework/contracts/diagnostics_test.go b/internal/framework/contracts/diagnostics_test.go index 8bb004b2..fce9a7c4 100644 --- a/internal/framework/contracts/diagnostics_test.go +++ b/internal/framework/contracts/diagnostics_test.go @@ -146,6 +146,47 @@ func TestCloneDiagnosticCollectionOwnsGroupsAndChunkIndex(t *testing.T) { } } +func TestProjectDiagnosticCollectionPartitionsAndChecksTotals(t *testing.T) { + warning := validDiagnosticGroup(DiagnosticDispositionWarning, DiagnosticCategoryFallback, "fallback", 2) + diagnostic := validDiagnosticGroup(DiagnosticDispositionAdvisory, DiagnosticCategoryDataQuality, "quality", 3) + collection := DiagnosticCollection{ + Groups: []DiagnosticGroup{warning, diagnostic}, + Truncated: true, + UnrepresentedOccurrenceCount: 4, + } + projection, err := ProjectDiagnosticCollection(collection) + if err != nil { + t.Fatal(err) + } + if len(projection.Warnings) != 1 || len(projection.Diagnostics) != 1 || projection.WarningOccurrenceCount != 2 || projection.DiagnosticOccurrenceCount != 7 { + t.Fatalf("projection = %#v, want partitioned exact totals", projection) + } + collection.Groups[0].Samples[0].Message = "mutated" + if projection.Warnings[0].Samples[0].Message != "message" { + t.Fatal("projection retained caller-owned sample storage") + } + + overflow := DiagnosticCollection{Groups: []DiagnosticGroup{ + validDiagnosticGroup(DiagnosticDispositionWarning, DiagnosticCategoryFallback, "first", int(^uint(0)>>1)), + validDiagnosticGroup(DiagnosticDispositionWarning, DiagnosticCategoryFallback, "second", 1), + }} + if _, err := ProjectDiagnosticCollection(overflow); err == nil { + t.Fatal("ProjectDiagnosticCollection() overflow error = nil") + } +} + +func validDiagnosticGroup(disposition DiagnosticDisposition, category DiagnosticCategory, reason string, occurrences int) DiagnosticGroup { + return DiagnosticGroup{ + Disposition: disposition, + Category: category, + ReasonCode: reason, + Origin: DiagnosticOrigin{Stage: DiagnosticOriginStageNormalize, StepID: "step", LaneID: "lane", ModuleKey: "module"}, + OccurrenceCount: occurrences, + Samples: []DiagnosticSample{{Scope: "scope", Message: "message"}}, + OmittedSampleCount: occurrences - 1, + } +} + func validProducerDiagnostic() ProducerDiagnostic { return ProducerDiagnostic{ Disposition: DiagnosticDispositionWarning, diff --git a/internal/framework/diagnostics/aggregator.go b/internal/framework/diagnostics/aggregator.go index ec97973b..097c956d 100644 --- a/internal/framework/diagnostics/aggregator.go +++ b/internal/framework/diagnostics/aggregator.go @@ -17,9 +17,10 @@ const ( type Aggregator struct { groups []contracts.DiagnosticGroup indices map[groupKey]int - omitted map[groupKey]struct{} warningGroups int nonWarningGroups int + warningOccurrences int + nonWarningOccurrences int unrepresentedOccurrences int } @@ -32,31 +33,62 @@ func (aggregator *Aggregator) Add(group contracts.DiagnosticGroup) error { } if aggregator.indices == nil { aggregator.indices = make(map[groupKey]int) - aggregator.omitted = make(map[groupKey]struct{}) } key := groupKeyFromGroup(group) if index, exists := aggregator.indices[key]; exists { - return aggregator.merge(index, group) - } - if _, exists := aggregator.omitted[key]; exists { - return aggregator.addUnrepresented(group.OccurrenceCount) + if err := aggregator.checkOccurrenceTotal(group.Disposition, group.OccurrenceCount); err != nil { + return err + } + if err := aggregator.merge(index, group); err != nil { + return err + } + aggregator.addOccurrenceTotal(group.Disposition, group.OccurrenceCount) + return nil } if group.Disposition == contracts.DiagnosticDispositionWarning { if aggregator.warningGroups >= MaxWarningGroups { return errors.New("diagnostic warning groups exceed maximum count") } - aggregator.warningGroups++ } else if aggregator.nonWarningGroups >= MaxNonWarningGroups { - aggregator.omitted[key] = struct{}{} + if err := aggregator.checkOccurrenceTotal(group.Disposition, group.OccurrenceCount); err != nil { + return err + } + aggregator.addOccurrenceTotal(group.Disposition, group.OccurrenceCount) return aggregator.addUnrepresented(group.OccurrenceCount) + } + if err := aggregator.checkOccurrenceTotal(group.Disposition, group.OccurrenceCount); err != nil { + return err + } + if group.Disposition == contracts.DiagnosticDispositionWarning { + aggregator.warningGroups++ } else { aggregator.nonWarningGroups++ } + aggregator.addOccurrenceTotal(group.Disposition, group.OccurrenceCount) aggregator.indices[key] = len(aggregator.groups) aggregator.groups = append(aggregator.groups, contracts.CloneDiagnosticCollection(contracts.DiagnosticCollection{Groups: []contracts.DiagnosticGroup{group}}).Groups[0]) return nil } +func (aggregator *Aggregator) checkOccurrenceTotal(disposition contracts.DiagnosticDisposition, count int) error { + current := aggregator.nonWarningOccurrences + if disposition == contracts.DiagnosticDispositionWarning { + current = aggregator.warningOccurrences + } + if count > maximumInt()-current { + return errors.New("diagnostic occurrence count overflow") + } + return nil +} + +func (aggregator *Aggregator) addOccurrenceTotal(disposition contracts.DiagnosticDisposition, count int) { + if disposition == contracts.DiagnosticDispositionWarning { + aggregator.warningOccurrences += count + return + } + aggregator.nonWarningOccurrences += count +} + // Collection returns an independently owned grouped result in first-occurrence // order. func (aggregator *Aggregator) Collection() contracts.DiagnosticCollection { diff --git a/internal/framework/diagnostics/aggregator_test.go b/internal/framework/diagnostics/aggregator_test.go index 7f7dcc66..c75fb565 100644 --- a/internal/framework/diagnostics/aggregator_test.go +++ b/internal/framework/diagnostics/aggregator_test.go @@ -46,7 +46,7 @@ func TestAggregatorEnforcesWarningBoundAndTruncatesOnlyNonWarnings(t *testing.T) } nonWarnings := Aggregator{} - for index := 0; index < MaxNonWarningGroups+1; index++ { + for index := 0; index < MaxNonWarningGroups+3; index++ { group := groupForAggregation("scope", "message", contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality, "module") group.ReasonCode = "advisory-" + string(rune('a'+index)) if err := nonWarnings.Add(group); err != nil { @@ -54,8 +54,26 @@ func TestAggregatorEnforcesWarningBoundAndTruncatesOnlyNonWarnings(t *testing.T) } } collection := nonWarnings.Collection() - if len(collection.Groups) != MaxNonWarningGroups || !collection.Truncated || collection.UnrepresentedOccurrenceCount != 1 { - t.Fatalf("collection = %#v, want bounded non-warning groups and one unrepresented occurrence", collection) + if len(collection.Groups) != MaxNonWarningGroups || !collection.Truncated || collection.UnrepresentedOccurrenceCount != 3 { + t.Fatalf("collection = %#v, want bounded non-warning groups and three unrepresented occurrences", collection) + } +} + +func TestAggregatorRejectsOccurrenceTotalOverflow(t *testing.T) { + aggregator := Aggregator{} + first := groupForAggregation("first", "first", contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality, "first") + first.OccurrenceCount = maximumInt() + first.OmittedSampleCount = maximumInt() - 1 + if err := aggregator.Add(first); err != nil { + t.Fatal(err) + } + second := groupForAggregation("second", "second", contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality, "second") + if err := aggregator.Add(second); err == nil { + t.Fatal("Add() occurrence total overflow error = nil") + } + collection := aggregator.Collection() + if len(collection.Groups) != 1 || collection.Groups[0].OccurrenceCount != maximumInt() { + t.Fatalf("collection changed after rejected overflow = %#v", collection) } } diff --git a/internal/framework/pipeline/producer_attempts.go b/internal/framework/pipeline/producer_attempts.go index 62986eac..3359dd87 100644 --- a/internal/framework/pipeline/producer_attempts.go +++ b/internal/framework/pipeline/producer_attempts.go @@ -193,6 +193,10 @@ func runProducerAttempts(ctx context.Context, config producerAttemptConfig, prod } return failedProducerAttempt(provenance), fmt.Errorf("producer failed after %d attempt(s): %w", number, err) } + if err := validateProducerAttemptDiagnostics(output); err != nil { + provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptFailed}) + return failedProducerAttempt(provenance), err + } output, err = output.clone() if err != nil { @@ -265,6 +269,18 @@ func runProducerAttempts(ctx context.Context, config producerAttemptConfig, prod return failedProducerAttempt(provenance), errors.New("producer attempt budget was not exhausted deterministically") } +func validateProducerAttemptDiagnostics(output producerAttemptOutput) error { + if err := contracts.ValidateProducerDiagnostics(output.Diagnostics); err != nil { + return fmt.Errorf("producer returned invalid diagnostics: %w", err) + } + if output.Retry != nil { + if err := contracts.ValidateProducerDiagnostics(output.Retry.FallbackDiagnostics); err != nil { + return fmt.Errorf("producer returned invalid retry fallback diagnostics: %w", err) + } + } + return nil +} + func isImmediateProducerFailure(err error) bool { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return true diff --git a/internal/framework/pipeline/producer_attempts_test.go b/internal/framework/pipeline/producer_attempts_test.go index 336fd930..f02f369f 100644 --- a/internal/framework/pipeline/producer_attempts_test.go +++ b/internal/framework/pipeline/producer_attempts_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "reflect" + "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" @@ -247,6 +248,45 @@ func TestRunProducerAttemptsUsesModuleRetryBudgetAndFallback(t *testing.T) { }) } +func TestRunProducerAttemptsRejectsInvalidDiagnosticsWithoutRetry(t *testing.T) { + tests := []struct { + name string + output producerAttemptOutput + want string + }{ + { + name: "producer diagnostics", + output: producerAttemptOutput{Diagnostics: []contracts.ProducerDiagnostic{{}}}, + want: "producer returned invalid diagnostics", + }, + { + name: "retry fallback diagnostics", + output: producerAttemptOutput{Retry: &producerRetryDirective{FallbackDiagnostics: []contracts.ProducerDiagnostic{{}}}}, + want: "producer returned invalid retry fallback diagnostics", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + producerCalls := 0 + validatorCalls := 0 + terminal, err := runProducerAttempts(context.Background(), producerAttemptConfig{Retries: 3, Policy: DefaultValidationPolicy()}, func(context.Context, producerAttemptRequest) (producerAttemptOutput, error) { + producerCalls++ + return test.output, nil + }, func(context.Context, producerAttemptOutput) (validationReport, error) { + validatorCalls++ + return validationReport{}, nil + }) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("runProducerAttempts() error = %v, want %q", err, test.want) + } + if terminal.Action != producerTerminalFailed || producerCalls != 1 || validatorCalls != 0 { + t.Fatalf("terminal = %#v, producer calls = %d, validator calls = %d; want immediate framework failure", terminal, producerCalls, validatorCalls) + } + }) + } +} + func TestRunProducerAttemptsRejectionWinsOverValidatorFailure(t *testing.T) { firstDiagnostics := []contracts.ProducerDiagnostic{producerDiagnostic("discarded", "discarded warning")} secondDiagnostics := []contracts.ProducerDiagnostic{producerDiagnostic("accepted", "accepted warning")} diff --git a/internal/framework/pipeline/runner_chunk_plan.go b/internal/framework/pipeline/runner_chunk_plan.go index 4437e436..0ffa447f 100644 --- a/internal/framework/pipeline/runner_chunk_plan.go +++ b/internal/framework/pipeline/runner_chunk_plan.go @@ -101,7 +101,7 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S return result, failure } // A cache hit is not model material. Its rejection is discarded and - // generation begins with the ordinary initial request below. Warnings + // generation begins with the ordinary initial request below. Diagnostics // from this discarded candidate are intentionally not promoted. } result.lookup = ChunkPlanDecision{Status: ChunkPlanInvalid, Reason: chunkPlanLookupReason(ChunkPlanInvalid)} diff --git a/internal/framework/pipeline/validation_executor.go b/internal/framework/pipeline/validation_executor.go index 066034fa..bb1f8d02 100644 --- a/internal/framework/pipeline/validation_executor.go +++ b/internal/framework/pipeline/validation_executor.go @@ -21,7 +21,7 @@ const ( const correctionRequestIntroduction = "The previous response failed semantic validation. Return one complete corrected replacement response, not a patch, explanation, or commentary.\n\nCorrect all of the following:\n" // validationRecord captures the settled result of one configured validator. -// Its fields remain private so reports cannot expose mutable warning storage. +// Its fields remain private so reports cannot expose mutable diagnostic storage. type validationRecord struct { validatorName string outcome validationOutcome @@ -188,6 +188,9 @@ func executeValidationChain(ctx context.Context, chain preparedValidatorChain, i report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationSkipped, attemptCount: attempt, reasonCode: invocation.reason, message: invocation.message}) break } + if err := contracts.ValidateProducerDiagnostics(invocation.result.Diagnostics); err != nil { + return validationReport{}, fatalValidationError(fmt.Errorf("validator %q returned invalid diagnostics: %w", binding.Module, err)) + } if err := contracts.ValidateValidationResult(invocation.result); err != nil { if attempt == attemptLimit { report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationFailed, attemptCount: attempt, message: "validator returned an invalid result", failure: err}) diff --git a/internal/framework/pipeline/validation_executor_test.go b/internal/framework/pipeline/validation_executor_test.go index e0acfcf8..5a2a3fc5 100644 --- a/internal/framework/pipeline/validation_executor_test.go +++ b/internal/framework/pipeline/validation_executor_test.go @@ -196,6 +196,19 @@ func TestExecuteValidationChainReturnsFrameworkAndCancellationErrors(t *testing. } } +func TestExecuteValidationChainRejectsInvalidDiagnosticsWithoutRetry(t *testing.T) { + chain := validationChain(validationSpec("validator", contracts.ExecutionClassLLMBacked, 2)) + calls := 0 + _, err := executeValidationChain(context.Background(), chain, func(context.Context, preparedValidator, int) (validationInvocation, error) { + calls++ + return validationInvocation{result: contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{{}}}}, nil + }) + var frameworkErr validationFrameworkError + if !errors.As(err, &frameworkErr) || calls != 1 || !strings.Contains(err.Error(), "validator \"validator\" returned invalid diagnostics") { + t.Fatalf("error = %v calls = %d, want one fatal invalid-diagnostics result", err, calls) + } +} + type validationStep struct { result contracts.ValidationResult invocation validationInvocation diff --git a/internal/framework/semanticreconcile/application.go b/internal/framework/semanticreconcile/application.go index f2ec73b2..38893f16 100644 --- a/internal/framework/semanticreconcile/application.go +++ b/internal/framework/semanticreconcile/application.go @@ -70,7 +70,7 @@ type ApplicationPolicy[T any] struct { } // GroupProvenance identifies the complete input contribution of one plan -// group without prescribing domain warning or retry policy. +// group without prescribing domain diagnostic or retry policy. type GroupProvenance struct { memberPositions []int canonicalPosition int @@ -108,7 +108,7 @@ func (event AppliedGroup) Provenance() GroupProvenance { return cloneGroupProvenance(event.provenance) } -// RejectedGroup records a typed guard decision while leaving warning and retry +// RejectedGroup records a typed guard decision while leaving diagnostic and retry // construction to the consuming domain. type RejectedGroup struct { category RejectionCategory diff --git a/internal/framework/semanticreconcile/proposal.go b/internal/framework/semanticreconcile/proposal.go index f44d5b08..f9d9a5bb 100644 --- a/internal/framework/semanticreconcile/proposal.go +++ b/internal/framework/semanticreconcile/proposal.go @@ -32,7 +32,7 @@ const ( ) // Issue identifies an unsafe proposal category at its original response group -// index without prescribing caller warning text. +// index without prescribing caller diagnostic text. type Issue struct { GroupIndex int Category IssueCategory diff --git a/internal/modules/dnd/normalize/combatturns/normalizer.go b/internal/modules/dnd/normalize/combatturns/normalizer.go index c8ac8dc2..91c542a8 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer.go @@ -112,8 +112,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize } index := source.NewDocumentIndex(req.Source) order := shared.NewSourceRefOrderFromIndex(index) - value, warnings := normalizeList(req.MergeOutput.Value, index, order, npcRegistry) - diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings) + value, findings := normalizeList(req.MergeOutput.Value, index, order, npcRegistry) + diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings) if err != nil { return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("collect diagnostics: %w", err) } @@ -138,7 +138,7 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, } records := make([]normalizedRecord, len(input.CombatTurns)) - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, inputTurn := range input.CombatTurns { turn, actorChange, refsChanged := normalizeTurn(inputTurn, order, registry) earliest, hasEvidence := order.EarliestValid(turn.SourceRefs) @@ -149,7 +149,7 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, hasEvidence: hasEvidence, } if actorChange != nil { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: turnScope(index), ReasonCode: ReasonCodeActorCanonicalized, Message: fmt.Sprintf("input index %d: actor canonicalized from %s to %s", @@ -157,7 +157,7 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, }) } if refsChanged { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: turnScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", @@ -179,7 +179,7 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, if position == record.inputIndex { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: turnScope(record.inputIndex), ReasonCode: ReasonCodeTurnsReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", @@ -187,9 +187,9 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, }) } - output, duplicateWarnings := collapseDuplicates(records, documentIndex) - warnings = append(warnings, duplicateWarnings...) - return dnd.CombatTurnList{CombatTurns: output}, warnings + output, duplicateFindings := collapseDuplicates(records, documentIndex) + findings = append(findings, duplicateFindings...) + return dnd.CombatTurnList{CombatTurns: output}, findings } func normalizeTurn(input dnd.CombatTurn, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) { @@ -267,14 +267,14 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen } } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) == 0 { continue } - warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) + findings = append(findings, duplicateFinding(group.retainedIndex, group.removed)) } - return output, warnings + return output, findings } func duplicateKey(turn dnd.CombatTurn, documentIndex source.DocumentIndex) (string, bool) { @@ -309,7 +309,7 @@ func writeKeyInt(builder *strings.Builder, value int) { builder.WriteByte(';') } -func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { +func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding { issues := make([]string, len(removed)) for index, removedIndex := range removed { issues[index] = fmt.Sprintf("removed input index %d", removedIndex) diff --git a/internal/modules/dnd/normalize/combatturns/normalizer_test.go b/internal/modules/dnd/normalize/combatturns/normalizer_test.go index 857fb73b..eafd6cd9 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer_test.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer_test.go @@ -59,7 +59,7 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) { } } -func TestNormalizeLimitsWarningsWithoutChangingCombatTurnValues(t *testing.T) { +func TestNormalizeLimitsFindingsWithoutChangingCombatTurnValues(t *testing.T) { units := make([]source.SourceUnit, contracts.MaxDiagnosticSamples+1) turns := make([]dnd.CombatTurn, len(units)) for index := range units { @@ -288,23 +288,23 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T } } -func TestDuplicateWarningBoundsDisplayedIndexesAndReportsAllOmissions(t *testing.T) { +func TestDuplicateFindingBoundsDisplayedIndexesAndReportsAllOmissions(t *testing.T) { removed := make([]int, 25) for index := range removed { removed[index] = math.MaxInt - index } - warning := duplicateWarning(7, removed) - if warning.Scope != "combat_turns[7]" || warning.ReasonCode != ReasonCodeDuplicateCollapsed { - t.Fatalf("duplicate warning = %#v, want retained-record scope and reason", warning) + finding := duplicateFinding(7, removed) + if finding.Scope != "combat_turns[7]" || finding.ReasonCode != ReasonCodeDuplicateCollapsed { + t.Fatalf("duplicate finding = %#v, want retained-record scope and reason", finding) } - if !strings.Contains(warning.Message, "retained input index 7") || !strings.Contains(warning.Message, fmt.Sprintf("removed input index %d", removed[0])) { - t.Fatalf("duplicate warning = %q, want retained and displayed removed indexes", warning.Message) + if !strings.Contains(finding.Message, "retained input index 7") || !strings.Contains(finding.Message, fmt.Sprintf("removed input index %d", removed[0])) { + t.Fatalf("duplicate finding = %q, want retained and displayed removed indexes", finding.Message) } - if !strings.Contains(warning.Message, "5 additional issue(s) omitted") { - t.Fatalf("duplicate warning = %q, want exact omitted count", warning.Message) + if !strings.Contains(finding.Message, "5 additional issue(s) omitted") { + t.Fatalf("duplicate finding = %q, want exact omitted count", finding.Message) } - if !utf8.ValidString(warning.Message) || len([]byte(warning.Message)) > 4096 { - t.Fatalf("duplicate warning length/encoding = %d/%t", len([]byte(warning.Message)), utf8.ValidString(warning.Message)) + if !utf8.ValidString(finding.Message) || len([]byte(finding.Message)) > 4096 { + t.Fatalf("duplicate finding length/encoding = %d/%t", len([]byte(finding.Message)), utf8.ValidString(finding.Message)) } } diff --git a/internal/modules/dnd/normalize/enemyevents/normalizer.go b/internal/modules/dnd/normalize/enemyevents/normalizer.go index 0b619826..d793f004 100644 --- a/internal/modules/dnd/normalize/enemyevents/normalizer.go +++ b/internal/modules/dnd/normalize/enemyevents/normalizer.go @@ -104,8 +104,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize } index := source.NewDocumentIndex(req.Source) order := shared.NewSourceRefOrderFromIndex(index) - value, warnings := normalizeList(req.MergeOutput.Value, order, registry) - diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings) + value, findings := normalizeList(req.MergeOutput.Value, order, registry) + diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings) if err != nil { return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("collect diagnostics: %w", err) } @@ -128,12 +128,12 @@ func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, regist return dnd.EnemyEventList{}, nil } records := make([]normalizedRecord, len(input.Events)) - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, inputEvent := range input.Events { event, nameChange, refsChanged := normalizeEvent(inputEvent, order, registry) records[index] = normalizedRecord{event: event, identity: enemyeventmodel.CanonicalIdentity(event), inputIndex: index} if nameChange != nil { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: eventScope(index), ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: subject canonicalized from %s to %s", @@ -141,7 +141,7 @@ func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, regist }) } if refsChanged { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: eventScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", @@ -157,16 +157,16 @@ func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, regist if position == record.inputIndex { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: eventScope(record.inputIndex), ReasonCode: ReasonCodeEventsReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position), }) } - output, duplicateWarnings := collapseDuplicates(records) - warnings = append(warnings, duplicateWarnings...) - return dnd.EnemyEventList{Events: output}, warnings + output, duplicateFindings := collapseDuplicates(records) + findings = append(findings, duplicateFindings...) + return dnd.EnemyEventList{Events: output}, findings } func normalizeEvent(input dnd.EnemyEvent, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEvent, *nameCanonicalization, bool) { @@ -235,7 +235,7 @@ func collapseDuplicates(records []normalizedRecord) ([]dnd.EnemyEvent, []diagnos for index, record := range kept { output[index] = cloneEvent(record.event) } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) == 0 { continue @@ -244,14 +244,14 @@ func collapseDuplicates(records []normalizedRecord) ([]dnd.EnemyEvent, []diagnos for index, removed := range group.removed { issues[index] = fmt.Sprintf("removed input index %d", removed) } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: eventScope(group.retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( fmt.Sprintf("duplicate enemy event collapsed; retained input index %d", group.retainedIndex), issues), }) } - return output, warnings + return output, findings } func eventScope(index int) string { return fmt.Sprintf("events[%d]", index) } diff --git a/internal/modules/dnd/normalize/enemyevents/normalizer_test.go b/internal/modules/dnd/normalize/enemyevents/normalizer_test.go index d9537a9d..34a3a194 100644 --- a/internal/modules/dnd/normalize/enemyevents/normalizer_test.go +++ b/internal/modules/dnd/normalize/enemyevents/normalizer_test.go @@ -134,7 +134,7 @@ func TestNormalizeRequiresRegistryAndKeepsOperationContentOutOfMetadata(t *testi } } -func TestNormalizerContractAndWarningBound(t *testing.T) { +func TestNormalizerContractAndFindingBound(t *testing.T) { normalizer := newNormalizer(t, npcReferences(t)) if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { t.Fatal("DecodeOptions() accepted an unknown option") diff --git a/internal/modules/dnd/normalize/itemoccurrences/normalizer.go b/internal/modules/dnd/normalize/itemoccurrences/normalizer.go index e37dc42d..35dc1fcf 100644 --- a/internal/modules/dnd/normalize/itemoccurrences/normalizer.go +++ b/internal/modules/dnd/normalize/itemoccurrences/normalizer.go @@ -106,8 +106,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("item registry reference is required") } index := source.NewDocumentIndex(req.Source) - value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry) - diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings, ReasonCodeUnknownItemID) + value, findings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry) + diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings, ReasonCodeUnknownItemID) if err != nil { return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("collect diagnostics: %w", err) } @@ -126,12 +126,12 @@ func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, ord } records := make([]normalizedRecord, len(input.Occurrences)) - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, inputOccurrence := range input.Occurrences { occurrence, changedFields, found, refsChanged := normalizeOccurrence(inputOccurrence, order, registry) records[index] = normalizedRecord{occurrence: occurrence, identity: itemoccurrencemodel.CanonicalExactIdentity(occurrence), inputIndex: index} if len(changedFields) != 0 { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: normalized display whitespace in %s", index, @@ -139,15 +139,15 @@ func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, ord }) } if found && inputOccurrence.Name != occurrence.Name { - warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized, + findings = append(findings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: item name canonicalized from %s to %s", index, diagnostics.Quote(inputOccurrence.Name), diagnostics.Quote(occurrence.Name))}) } if !found { - warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeUnknownItemID, + findings = append(findings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeUnknownItemID, Message: fmt.Sprintf("input index %d: item ID %s is not in the supplied registry", index, diagnostics.Quote(inputOccurrence.ItemID))}) } if refsChanged { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", @@ -163,16 +163,16 @@ func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, ord if position == record.inputIndex { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeOccurrencesReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position), }) } - output, duplicateWarnings := collapseDuplicates(records, index) - warnings = append(warnings, duplicateWarnings...) - return dnd.ItemOccurrenceList{Occurrences: output}, warnings + output, duplicateFindings := collapseDuplicates(records, index) + findings = append(findings, duplicateFindings...) + return dnd.ItemOccurrenceList{Occurrences: output}, findings } func normalizeOccurrence(input dnd.ItemOccurrence, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrence, []string, bool, bool) { @@ -253,16 +253,16 @@ func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex) output = append(output, cloneOccurrence(record.occurrence)) } } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) != 0 { - warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) + findings = append(findings, duplicateFinding(group.retainedIndex, group.removed)) } } - return output, warnings + return output, findings } -func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { +func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding { issues := make([]string, len(removed)) for index, removedIndex := range removed { issues[index] = fmt.Sprintf("removed input index %d", removedIndex) diff --git a/internal/modules/dnd/normalize/itemregistry/normalizer.go b/internal/modules/dnd/normalize/itemregistry/normalizer.go index bbdc154b..081c3db7 100644 --- a/internal/modules/dnd/normalize/itemregistry/normalizer.go +++ b/internal/modules/dnd/normalize/itemregistry/normalizer.go @@ -247,7 +247,7 @@ func preprocessRecords(input dnd.ItemRegistry, order shared.SourceRefOrder) ([]n retained.inputIndexes = sortedUniqueIndexes(retained.inputIndexes) output = append(output, retained) if len(members) > 1 { - findings = append(findings, duplicateWarning(retained.earliest, memberInputIndexes(records, members[1:]))) + findings = append(findings, duplicateFinding(retained.earliest, memberInputIndexes(records, members[1:]))) } } return output, findings @@ -328,7 +328,7 @@ func recordList(records []normalizedRecord) dnd.ItemRegistry { return dnd.ItemRegistry{Items: recordValues(records)} } -func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { +func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding { const maxDisplayedIndices = 20 displayed := removed if len(displayed) > maxDisplayedIndices { diff --git a/internal/modules/dnd/normalize/itemregistry/normalizer_test.go b/internal/modules/dnd/normalize/itemregistry/normalizer_test.go index 47c52bf6..006e8de0 100644 --- a/internal/modules/dnd/normalize/itemregistry/normalizer_test.go +++ b/internal/modules/dnd/normalize/itemregistry/normalizer_test.go @@ -314,7 +314,7 @@ func TestNormalizeLimitSkipDoesNotCallLLMAndAddsBoundedFallbackWarning(t *testin } } -func TestNormalizeRetryFallbackErrorsWarningsAndIdempotence(t *testing.T) { +func TestNormalizeRetryFallbackErrorsAndIdempotence(t *testing.T) { doc := semanticDocument() input := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Star Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}}} invalid, err := newNormalizer(t, &recordingNormalizerClient{err: contracts.ErrInvalidStructuredOutput}).Normalize(context.Background(), normalizeRequestWithSource(input, doc)) diff --git a/internal/modules/dnd/normalize/locationoccurrences/normalizer.go b/internal/modules/dnd/normalize/locationoccurrences/normalizer.go index 5f751424..94d19442 100644 --- a/internal/modules/dnd/normalize/locationoccurrences/normalizer.go +++ b/internal/modules/dnd/normalize/locationoccurrences/normalizer.go @@ -107,8 +107,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize return contracts.TypedNormalizeResult[dnd.LocationOccurrenceList]{}, normalizerErrorf("location registry reference is required") } index := source.NewDocumentIndex(req.Source) - value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry) - diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings, ReasonCodeUnknownLocationID) + value, findings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry) + diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings, ReasonCodeUnknownLocationID) if err != nil { return contracts.TypedNormalizeResult[dnd.LocationOccurrenceList]{}, normalizerErrorf("collect diagnostics: %w", err) } @@ -127,20 +127,20 @@ func normalizeList(input dnd.LocationOccurrenceList, documentIndex source.Docume return dnd.LocationOccurrenceList{}, nil } records := make([]normalizedRecord, len(input.Occurrences)) - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, inputOccurrence := range input.Occurrences { occurrence, change, found, refsChanged := normalizeOccurrence(inputOccurrence, order, registry) records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index} if change != nil { - warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized, + findings = append(findings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: location name canonicalized from %s to %s", index, diagnostics.Quote(change.from), diagnostics.Quote(change.to))}) } if !found { - warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeUnknownLocationID, + findings = append(findings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeUnknownLocationID, Message: fmt.Sprintf("input index %d: location ID %s is not in the supplied registry", index, diagnostics.Quote(inputOccurrence.LocationID))}) } if refsChanged { - warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, + findings = append(findings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputOccurrence.SourceRefs), len(occurrence.SourceRefs))}) } } @@ -149,13 +149,13 @@ func normalizeList(input dnd.LocationOccurrenceList, documentIndex source.Docume }) for position, record := range records { if position != record.inputIndex { - warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeOccurrencesReordered, + findings = append(findings, diagnostics.Finding{Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeOccurrencesReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d by canonical occurrence order", record.inputIndex, position)}) } } - output, duplicateWarnings := collapseDuplicates(records, documentIndex) - warnings = append(warnings, duplicateWarnings...) - return dnd.LocationOccurrenceList{Occurrences: output}, warnings + output, duplicateFindings := collapseDuplicates(records, documentIndex) + findings = append(findings, duplicateFindings...) + return dnd.LocationOccurrenceList{Occurrences: output}, findings } func normalizeOccurrence(input dnd.LocationOccurrence, order shared.SourceRefOrder, registry *locationregistry.Registry) (dnd.LocationOccurrence, *nameCanonicalization, bool, bool) { @@ -222,13 +222,13 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen output = append(output, cloneOccurrence(record.occurrence)) } } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) > 0 { - warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) + findings = append(findings, duplicateFinding(group.retainedIndex, group.removed)) } } - return output, warnings + return output, findings } func validSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool { @@ -316,7 +316,7 @@ func sourceRefsLess(order shared.SourceRefOrder, left, right []source.SourceRef) return len(left) < len(right) } -func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { +func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding { issues := make([]string, len(removed)) for index, removedIndex := range removed { issues[index] = fmt.Sprintf("removed input index %d", removedIndex) diff --git a/internal/modules/dnd/normalize/locationoccurrences/normalizer_test.go b/internal/modules/dnd/normalize/locationoccurrences/normalizer_test.go index 89c5fff7..b2ce1d92 100644 --- a/internal/modules/dnd/normalize/locationoccurrences/normalizer_test.go +++ b/internal/modules/dnd/normalize/locationoccurrences/normalizer_test.go @@ -91,7 +91,7 @@ func TestNormalizePreservesUnknownIDsAndMalformedOperationRegistry(t *testing.T) } } -func TestNormalizerContractsRequiredRegistryAndWarningBounds(t *testing.T) { +func TestNormalizerContractsRequiredRegistryAndFindingBounds(t *testing.T) { if _, err := New(Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") { t.Fatalf("New() error = %v", err) } diff --git a/internal/modules/dnd/normalize/locationregistry/normalizer.go b/internal/modules/dnd/normalize/locationregistry/normalizer.go index 99728b66..59168d03 100644 --- a/internal/modules/dnd/normalize/locationregistry/normalizer.go +++ b/internal/modules/dnd/normalize/locationregistry/normalizer.go @@ -209,18 +209,18 @@ func preprocessRecords(input dnd.LocationRegistry, order shared.SourceRefOrder) return nil, nil } records := make([]normalizedRecord, len(input.Locations)) - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, inputLocation := range input.Locations { location, fieldsChanged, refsChanged := normalizeRecord(inputLocation, order) records[index] = normalizedRecord{location: location, inputIndexes: []int{index}, earliest: index} if fieldsChanged { - warnings = append(warnings, diagnostics.Finding{Scope: locationScope(index), ReasonCode: ReasonCodeLocationFieldsNormalized, Message: fmt.Sprintf("input index %d: location name normalized for %s", index, diagnostics.Quote(inputLocation.Name))}) + findings = append(findings, diagnostics.Finding{Scope: locationScope(index), ReasonCode: ReasonCodeLocationFieldsNormalized, Message: fmt.Sprintf("input index %d: location name normalized for %s", index, diagnostics.Quote(inputLocation.Name))}) } if refsChanged { - warnings = append(warnings, diagnostics.Finding{Scope: locationScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputLocation.SourceRefs), len(location.SourceRefs))}) + findings = append(findings, diagnostics.Finding{Scope: locationScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputLocation.SourceRefs), len(location.SourceRefs))}) } if inputLocation.ID != location.ID { - warnings = append(warnings, diagnostics.Finding{Scope: locationScope(index), ReasonCode: ReasonCodeLocationIDRecomputed, Message: fmt.Sprintf("input index %d: location ID recomputed from %s", index, diagnostics.Quote(location.Name))}) + findings = append(findings, diagnostics.Finding{Scope: locationScope(index), ReasonCode: ReasonCodeLocationIDRecomputed, Message: fmt.Sprintf("input index %d: location ID recomputed from %s", index, diagnostics.Quote(location.Name))}) } } groups := exactDuplicateGroups(records) @@ -233,10 +233,10 @@ func preprocessRecords(input dnd.LocationRegistry, order shared.SourceRefOrder) retained.inputIndexes = sortedUniqueIndexes(retained.inputIndexes) output = append(output, retained) if len(members) > 1 { - warnings = append(warnings, duplicateWarning(retained.earliest, memberInputIndexes(records, members[1:]))) + findings = append(findings, duplicateFinding(retained.earliest, memberInputIndexes(records, members[1:]))) } } - return output, warnings + return output, findings } func normalizeRecord(input dnd.Location, order shared.SourceRefOrder) (dnd.Location, bool, bool) { @@ -342,7 +342,7 @@ func recordList(records []normalizedRecord) dnd.LocationRegistry { return dnd.LocationRegistry{Locations: recordValues(records)} } -func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { +func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding { const maxDisplayedIndices = 20 displayed := removed if len(displayed) > maxDisplayedIndices { diff --git a/internal/modules/dnd/normalize/locationregistry/normalizer_test.go b/internal/modules/dnd/normalize/locationregistry/normalizer_test.go index 1bb25fa4..d5f9eea9 100644 --- a/internal/modules/dnd/normalize/locationregistry/normalizer_test.go +++ b/internal/modules/dnd/normalize/locationregistry/normalizer_test.go @@ -73,7 +73,7 @@ func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t t.Fatalf("locations = %#v, want same names and nested place retained", got) } if result.Value.Locations[0].ID == result.Value.Locations[1].ID || !hasDiagnostic(result.Diagnostics, ReasonCodeDuplicateLocationCollapsed, contracts.DiagnosticDispositionObservation) || len(client.requests) != 0 { - t.Fatalf("result = %#v, want evidence-anchored IDs and exact duplicate warning", result) + t.Fatalf("result = %#v, want evidence-anchored IDs and exact duplicate finding", result) } } diff --git a/internal/modules/dnd/normalize/locationregistry/reconciliation.go b/internal/modules/dnd/normalize/locationregistry/reconciliation.go index 7d773e60..975b5e95 100644 --- a/internal/modules/dnd/normalize/locationregistry/reconciliation.go +++ b/internal/modules/dnd/normalize/locationregistry/reconciliation.go @@ -54,15 +54,15 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe earliest: record.EarliestInputPosition(), } } - warnings := make([]diagnostics.Finding, 0, len(application.AppliedGroups())) + findings := make([]diagnostics.Finding, 0, len(application.AppliedGroups())) for _, event := range application.AppliedGroups() { provenance := event.Provenance() - warnings = append(warnings, semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()])) + findings = append(findings, semanticDuplicateFinding(provenance, records[provenance.CanonicalPosition()])) } - return output, warnings, nil + return output, findings, nil } -func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding { +func semanticDuplicateFinding(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding { inputIndexes := provenance.OriginalInputIndexes() details := make([]string, 0, len(inputIndexes)+1) for _, inputIndex := range inputIndexes { diff --git a/internal/modules/dnd/normalize/npcoccurrences/normalizer.go b/internal/modules/dnd/normalize/npcoccurrences/normalizer.go index fa0f0295..3cf07c1e 100644 --- a/internal/modules/dnd/normalize/npcoccurrences/normalizer.go +++ b/internal/modules/dnd/normalize/npcoccurrences/normalizer.go @@ -108,11 +108,11 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize } index := source.NewDocumentIndex(req.Source) order := shared.NewSourceRefOrderFromIndex(index) - value, warnings, err := normalizeList(req.MergeOutput.Value, index, order, registry) + value, findings, err := normalizeList(req.MergeOutput.Value, index, order, registry) if err != nil { return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("validate NPC registry pairs: %w", err) } - diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings) + diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings) if err != nil { return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("collect diagnostics: %w", err) } @@ -130,7 +130,7 @@ func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentInd } records := make([]normalizedRecord, len(input.Occurrences)) - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, inputOccurrence := range input.Occurrences { occurrence, refsChanged, err := normalizeOccurrence(inputOccurrence, order, registry) if err != nil { @@ -138,7 +138,7 @@ func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentInd } records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index} if refsChanged { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", @@ -154,16 +154,16 @@ func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentInd if position == record.inputIndex { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeOccurrencesReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position), }) } - output, duplicateWarnings := collapseDuplicates(records, documentIndex) - warnings = append(warnings, duplicateWarnings...) - return dnd.NPCOccurrenceList{Occurrences: output}, warnings, nil + output, duplicateFindings := collapseDuplicates(records, documentIndex) + findings = append(findings, duplicateFindings...) + return dnd.NPCOccurrenceList{Occurrences: output}, findings, nil } func normalizeOccurrence(input dnd.NPCOccurrence, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrence, bool, error) { @@ -220,16 +220,16 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen output = append(output, cloneOccurrence(record.occurrence)) } } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) != 0 { - warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) + findings = append(findings, duplicateFinding(group.retainedIndex, group.removed)) } } - return output, warnings + return output, findings } -func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { +func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding { issues := make([]string, len(removed)) for index, removedIndex := range removed { issues[index] = fmt.Sprintf("removed input index %d", removedIndex) diff --git a/internal/modules/dnd/normalize/npcoccurrences/normalizer_test.go b/internal/modules/dnd/normalize/npcoccurrences/normalizer_test.go index cecfcd8f..8011276e 100644 --- a/internal/modules/dnd/normalize/npcoccurrences/normalizer_test.go +++ b/internal/modules/dnd/normalize/npcoccurrences/normalizer_test.go @@ -40,7 +40,7 @@ func TestNormalizeValidatesPairsAndClones(t *testing.T) { } second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: result.Value}}) if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Diagnostics) != 0 { - t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err) + t.Fatalf("second normalization = %#v, %v; want idempotent output without findings", second, err) } result.Value.Occurrences[0].SourceRefs[0].StartUnitID = 999 if input.Occurrences[0].SourceRefs[0].StartUnitID == 999 { @@ -120,7 +120,7 @@ func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) { } } -func TestNormalizerContractAndDeterministicWarnings(t *testing.T) { +func TestNormalizerContractAndDeterministicFindings(t *testing.T) { normalizer, err := New(Options{}, npcReferences(t)) if err != nil { t.Fatal(err) @@ -139,7 +139,7 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) { } } -func TestNormalizeBoundsWarnings(t *testing.T) { +func TestNormalizeBoundsFindings(t *testing.T) { count := contracts.MaxDiagnosticSamples + 5 doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} input := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, count)} diff --git a/internal/modules/dnd/normalize/npcregistry/normalizer.go b/internal/modules/dnd/normalize/npcregistry/normalizer.go index 05b53b15..c4b64ffb 100644 --- a/internal/modules/dnd/normalize/npcregistry/normalizer.go +++ b/internal/modules/dnd/normalize/npcregistry/normalizer.go @@ -208,18 +208,18 @@ func preprocessRecords(input dnd.NPCRegistry, order shared.SourceRefOrder) ([]no return nil, nil } records := make([]normalizedRecord, len(input.NPCs)) - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, inputNPC := range input.NPCs { npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC, order) records[index] = normalizedRecord{npc: npc, inputIndexes: []int{index}, earliest: index} if fieldsChanged { - warnings = append(warnings, diagnostics.Finding{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))}) + findings = append(findings, diagnostics.Finding{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))}) } if referencesChanged { - warnings = append(warnings, diagnostics.Finding{Scope: npcScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputNPC.SourceRefs), len(npc.SourceRefs))}) + findings = append(findings, diagnostics.Finding{Scope: npcScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputNPC.SourceRefs), len(npc.SourceRefs))}) } if inputNPC.ID != npc.ID { - warnings = append(warnings, diagnostics.Finding{Scope: npcScope(index), ReasonCode: ReasonCodeNPCIDRecomputed, Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", index, diagnostics.Quote(npc.Name))}) + findings = append(findings, diagnostics.Finding{Scope: npcScope(index), ReasonCode: ReasonCodeNPCIDRecomputed, Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", index, diagnostics.Quote(npc.Name))}) } } @@ -230,13 +230,13 @@ func preprocessRecords(input dnd.NPCRegistry, order shared.SourceRefOrder) ([]no output = append(output, consolidated) retainedIndex := consolidated.earliest if referencesChanged { - warnings = append(warnings, diagnostics.Finding{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", retainedIndex, len(consolidated.npc.SourceRefs))}) + findings = append(findings, diagnostics.Finding{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", retainedIndex, len(consolidated.npc.SourceRefs))}) } if len(members) > 1 { - warnings = append(warnings, duplicateWarning(retainedIndex, memberInputIndexes(records, members[1:]))) + findings = append(findings, duplicateFinding(retainedIndex, memberInputIndexes(records, members[1:]))) } } - return output, warnings + return output, findings } func normalizeRecord(input dnd.NPC, order shared.SourceRefOrder) (dnd.NPC, bool, bool) { @@ -340,7 +340,7 @@ func cloneSourceRefs(input []source.SourceRef) []source.SourceRef { return append([]source.SourceRef(nil), input...) } -func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { +func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding { const maxDisplayedIndices = 20 displayed := removed if len(displayed) > maxDisplayedIndices { diff --git a/internal/modules/dnd/normalize/npcregistry/reconciliation.go b/internal/modules/dnd/normalize/npcregistry/reconciliation.go index ea303a44..ac916585 100644 --- a/internal/modules/dnd/normalize/npcregistry/reconciliation.go +++ b/internal/modules/dnd/normalize/npcregistry/reconciliation.go @@ -54,15 +54,15 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe earliest: record.EarliestInputPosition(), } } - warnings := make([]diagnostics.Finding, 0, len(application.AppliedGroups())) + findings := make([]diagnostics.Finding, 0, len(application.AppliedGroups())) for _, event := range application.AppliedGroups() { provenance := event.Provenance() - warnings = append(warnings, semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()])) + findings = append(findings, semanticDuplicateFinding(provenance, records[provenance.CanonicalPosition()])) } - return output, warnings, nil + return output, findings, nil } -func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding { +func semanticDuplicateFinding(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding { inputIndexes := provenance.OriginalInputIndexes() details := make([]string, 0, len(inputIndexes)+1) for _, inputIndex := range inputIndexes { diff --git a/internal/modules/dnd/normalize/scenedescriptions/normalizer.go b/internal/modules/dnd/normalize/scenedescriptions/normalizer.go index 2beb8c16..0de8305e 100644 --- a/internal/modules/dnd/normalize/scenedescriptions/normalizer.go +++ b/internal/modules/dnd/normalize/scenedescriptions/normalizer.go @@ -63,11 +63,11 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize if err := ctx.Err(); err != nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("context error before normalize: %w", err) } - value, warnings, err := normalizeList(req.MergeOutput.Value, req.Source) + value, findings, err := normalizeList(req.MergeOutput.Value, req.Source) if err != nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("normalize scenes: %w", err) } - diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings) + diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings) if err != nil { return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("collect diagnostics: %w", err) } @@ -92,7 +92,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) ( documentIndex := source.NewDocumentIndex(doc) records := make([]normalizedScene, len(input.Scenes)) - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for sceneIndex, scene := range input.Scenes { originalTitle, originalSummary := scene.Title, scene.Summary scene.Title = strings.TrimSpace(scene.Title) @@ -104,7 +104,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) ( return dnd.SceneDescriptionList{}, nil, fmt.Errorf("scenes[%d].source_ref: %s", sceneIndex, diagnostics.Truncate(err.Error())) } if originalTitle != scene.Title || originalSummary != scene.Summary { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: sceneScope(sceneIndex), ReasonCode: ReasonCodeProseNormalized, Message: fmt.Sprintf("input index %d: title and/or summary whitespace normalized", sceneIndex), @@ -125,7 +125,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) ( if position == record.inputIndex { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: sceneScope(record.inputIndex), ReasonCode: ReasonCodeOrderNormalized, Message: fmt.Sprintf("input index %d moved to normalized position %d by canonical scene order", @@ -146,7 +146,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) ( return dnd.SceneDescriptionList{}, nil, fmt.Errorf("source range %s has conflicting records", sourceRefLabel(scene.SourceRef)) } if retainedIndex, ok := seen[scene]; ok { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: sceneScope(record.inputIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: fmt.Sprintf("input index %d: exact duplicate scene collapsed; retained input index %d", @@ -159,7 +159,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) ( seen[scene] = record.inputIndex unique = append(unique, scene) } - return dnd.SceneDescriptionList{Scenes: unique}, warnings, nil + return dnd.SceneDescriptionList{Scenes: unique}, findings, nil } func sceneScope(index int) string { return fmt.Sprintf("scenes[%d]", index) } diff --git a/internal/modules/dnd/normalize/scenedescriptions/normalizer_test.go b/internal/modules/dnd/normalize/scenedescriptions/normalizer_test.go index 0027fd54..69116772 100644 --- a/internal/modules/dnd/normalize/scenedescriptions/normalizer_test.go +++ b/internal/modules/dnd/normalize/scenedescriptions/normalizer_test.go @@ -53,7 +53,7 @@ func TestNormalizeTrimsOrdersDeduplicatesAndOwnsOutput(t *testing.T) { } } -func TestNormalizeLimitsCombinedSceneMutationWarnings(t *testing.T) { +func TestNormalizeLimitsCombinedSceneMutationFindings(t *testing.T) { count := contracts.MaxDiagnosticSamples + 1 doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} input := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, count)} diff --git a/internal/modules/dnd/normalize/spells/fixture_test.go b/internal/modules/dnd/normalize/spells/fixture_test.go index 742eda6c..405f02e4 100644 --- a/internal/modules/dnd/normalize/spells/fixture_test.go +++ b/internal/modules/dnd/normalize/spells/fixture_test.go @@ -15,10 +15,10 @@ type normalizerFixtureSet struct { } type normalizerFixtureCase struct { - Name string `json:"name"` - Input dnd.SpellList `json:"input"` - Output dnd.SpellList `json:"output"` - WarningReasonCodes []string `json:"warning_reason_codes"` + Name string `json:"name"` + Input dnd.SpellList `json:"input"` + Output dnd.SpellList `json:"output"` + DiagnosticReasonCodes []string `json:"diagnostic_reason_codes"` } func TestNormalizeAcceptedFixtures(t *testing.T) { @@ -48,8 +48,8 @@ func TestNormalizeAcceptedFixtures(t *testing.T) { for _, diagnostic := range result.Diagnostics { gotReasonCodes = append(gotReasonCodes, diagnostic.ReasonCode) } - if !reflect.DeepEqual(gotReasonCodes, fixture.WarningReasonCodes) { - t.Fatalf("warning reason codes = %#v, want %#v", gotReasonCodes, fixture.WarningReasonCodes) + if !reflect.DeepEqual(gotReasonCodes, fixture.DiagnosticReasonCodes) { + t.Fatalf("diagnostic reason codes = %#v, want %#v", gotReasonCodes, fixture.DiagnosticReasonCodes) } }) } diff --git a/internal/modules/dnd/normalize/spells/normalizer.go b/internal/modules/dnd/normalize/spells/normalizer.go index bc08debf..0bfa4ad5 100644 --- a/internal/modules/dnd/normalize/spells/normalizer.go +++ b/internal/modules/dnd/normalize/spells/normalizer.go @@ -100,10 +100,10 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize index := source.NewDocumentIndex(req.Source) order := shared.NewSourceRefOrderFromIndex(index) - value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog, order) - value, duplicateWarnings := collapseDuplicateSpellCasts(value, index, n.effectiveCatalog) - warnings = append(warnings, duplicateWarnings...) - diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings, ReasonCodeSpellNameUnresolved) + value, findings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog, order) + value, duplicateFindings := collapseDuplicateSpellCasts(value, index, n.effectiveCatalog) + findings = append(findings, duplicateFindings...) + diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings, ReasonCodeSpellNameUnresolved) if err != nil { return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("collect diagnostics: %w", err) } @@ -111,7 +111,7 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize } func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog, order shared.SourceRefOrder) (dnd.SpellList, []diagnostics.Finding) { - var warnings []diagnostics.Finding + var findings []diagnostics.Finding if input.SpellCasts == nil { return dnd.SpellList{}, nil } @@ -121,7 +121,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal cast := cloneSpellCast(inputCast) if canonicalName, ok := catalog.Lookup(inputCast.Spell); ok { if inputCast.Spell != canonicalName { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: spellCastScope(index), ReasonCode: ReasonCodeSpellNameCanonicalized, Message: fmt.Sprintf("input index %d: spell name canonicalized from %q to %q", @@ -130,7 +130,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal } cast.Spell = canonicalName } else { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: spellCastScope(index), ReasonCode: ReasonCodeSpellNameUnresolved, Message: fmt.Sprintf("input index %d: spell name %q could not be resolved in the effective catalog", @@ -141,7 +141,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(order, inputCast.SourceRefs) cast.SourceRefs = canonicalRefs if orderChanged || duplicateCount > 0 { - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: spellCastScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d, order changed %t, duplicates removed %d)", @@ -150,7 +150,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal } output.SpellCasts[index] = cast } - return output, warnings + return output, findings } func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast { @@ -221,14 +221,14 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, documentIndex source.Docum } } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) == 0 { continue } - warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) + findings = append(findings, duplicateFinding(group.retainedIndex, group.removed)) } - return output, warnings + return output, findings } func duplicateKey(cast dnd.SpellCast, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (string, bool) { @@ -264,7 +264,7 @@ func writeKeyInt(builder *strings.Builder, value int) { builder.WriteByte(';') } -func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { +func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding { const maxDisplayedIndices = 20 displayed := removed if len(displayed) > maxDisplayedIndices { diff --git a/internal/modules/dnd/normalize/spells/normalizer_test.go b/internal/modules/dnd/normalize/spells/normalizer_test.go index e60c3396..7a3194ce 100644 --- a/internal/modules/dnd/normalize/spells/normalizer_test.go +++ b/internal/modules/dnd/normalize/spells/normalizer_test.go @@ -155,7 +155,7 @@ func TestNormalizeCanonicalizesNamesAndReportsUnresolvedNames(t *testing.T) { } } -func TestNormalizeLimitsWarningsWithoutChangingSpellValues(t *testing.T) { +func TestNormalizeLimitsFindingsWithoutChangingSpellValues(t *testing.T) { input := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, contracts.MaxDiagnosticSamples+1)} for index := range input.SpellCasts { input.SpellCasts[index].Spell = fmt.Sprintf("Unknown Spell %d", index) @@ -241,7 +241,7 @@ func TestNormalizeReportsDuplicateRemovalWithoutOrderChange(t *testing.T) { } message := diagnostic.Samples[0].Message if !strings.Contains(message, "order changed false") || !strings.Contains(message, "duplicates removed 1") { - t.Fatalf("warning = %q, want duplicate-only repair without order change", message) + t.Fatalf("finding = %q, want duplicate-only repair without order change", message) } } @@ -406,7 +406,7 @@ func TestNormalizeDoesNotCollapseAdjacentOrOverlappingEvidence(t *testing.T) { } } -func TestNormalizeBoundsDuplicateWarningIndices(t *testing.T) { +func TestNormalizeBoundsDuplicateFindingIndices(t *testing.T) { doc := sourceDocument(2) ref := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1} casts := make([]dnd.SpellCast, 22) @@ -423,7 +423,7 @@ func TestNormalizeBoundsDuplicateWarningIndices(t *testing.T) { } message := diagnostic.Samples[0].Message if !strings.Contains(message, "removed input indices [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]") || strings.Contains(message, ", 21]") || !strings.Contains(message, "1 additional removed input indices omitted") { - t.Fatalf("warning message = %q, want 20 displayed indices and exact omitted count", message) + t.Fatalf("finding message = %q, want 20 displayed indices and exact omitted count", message) } } diff --git a/internal/modules/dnd/normalize/spells/testdata/normalizer_accepted_cases.json b/internal/modules/dnd/normalize/spells/testdata/normalizer_accepted_cases.json index 7f41f8b8..1a5ffbba 100644 --- a/internal/modules/dnd/normalize/spells/testdata/normalizer_accepted_cases.json +++ b/internal/modules/dnd/normalize/spells/testdata/normalizer_accepted_cases.json @@ -49,7 +49,7 @@ } ] }, - "warning_reason_codes": [ + "diagnostic_reason_codes": [ "spell_name_canonicalized", "source_references_normalized", "duplicate_spell_cast_collapsed" @@ -79,7 +79,7 @@ } ] }, - "warning_reason_codes": [] + "diagnostic_reason_codes": [] } ] } diff --git a/internal/modules/dnd/validate/combatturns/invariants/validator_test.go b/internal/modules/dnd/validate/combatturns/invariants/validator_test.go index bb1f820c..456be22b 100644 --- a/internal/modules/dnd/validate/combatturns/invariants/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/invariants/validator_test.go @@ -16,7 +16,7 @@ import ( func TestValidatorApprovesNormalizedCombatTurns(t *testing.T) { result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: invariantDocument(), Value: normalizedList()}) if err != nil || !result.Approved { - t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err) + t.Fatalf("Validate() = %#v, %v; want approval without diagnostics", result, err) } } diff --git a/internal/modules/dnd/validate/combatturns/shape/validator_test.go b/internal/modules/dnd/validate/combatturns/shape/validator_test.go index c60b823b..cdbc330a 100644 --- a/internal/modules/dnd/validate/combatturns/shape/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/shape/validator_test.go @@ -15,7 +15,7 @@ import ( func TestValidatorApprovesWellFormedCombatTurnList(t *testing.T) { result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Value: validCombatTurnList()}) if err != nil || !result.Approved { - t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err) + t.Fatalf("Validate() = %#v, %v; want approval without diagnostics", result, err) } } diff --git a/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go b/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go index ed587a59..40a7b71a 100644 --- a/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go @@ -86,7 +86,7 @@ func TestValidatorDefersMalformedShape(t *testing.T) { value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value}) if err != nil || !result.Approved { - t.Fatalf("shape deferral = %#v, %v; want approval without source warning", result, err) + t.Fatalf("shape deferral = %#v, %v; want approval without diagnostics", result, err) } } diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go index 90b06fa7..93366bb2 100644 --- a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go @@ -50,13 +50,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[turnIndex] = citedText } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for turnIndex, turn := range req.Value.CombatTurns { citedText := citedTexts[turnIndex] if actorAppearsInCitedText(citedText, turn.Actor) { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: fmt.Sprintf("combat_turns[%d]", turnIndex), ReasonCode: ReasonCode, Message: diagnostics.Aggregate("combat turn not near source", []string{ @@ -64,7 +64,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq }), }) } - return diagnostics.DataQualityResult(warnings) + return diagnostics.DataQualityResult(findings) } func actorAppearsInCitedText(citedText string, actor string) bool { return shared.ContainsTokenSequence(citedText, actor) diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go index 835df64a..a948a292 100644 --- a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go @@ -46,7 +46,7 @@ func TestValidatorWarnsOncePerTurnForUnrelatedActor(t *testing.T) { } } -func TestValidatorLimitsUnrelatedActorWarnings(t *testing.T) { +func TestValidatorLimitsUnrelatedActorAdvisories(t *testing.T) { turns := make([]dnd.CombatTurn, contracts.MaxDiagnosticSamples+2) for index := range turns { turns[index] = dnd.CombatTurn{ diff --git a/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go index 793e13d7..3d0636b8 100644 --- a/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go @@ -42,13 +42,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq if err != nil { return contracts.ValidationResult{Approved: true}, nil } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for eventIndex, event := range req.Value.Events { citedText, err := resolver.CitedText(event.SourceRefs) if err != nil || shared.ContainsTokenSequence(citedText, event.Name) { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: fmt.Sprintf("events[%d]", eventIndex), ReasonCode: ReasonCode, Message: diagnostics.Aggregate("enemy event subject not near source", []string{ @@ -56,7 +56,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq }), }) } - return diagnostics.DataQualityResult(warnings) + return diagnostics.DataQualityResult(findings) } func Spec() pipeline.ValidatorSpec { diff --git a/internal/modules/dnd/validate/enemyevents/source_relatedness/validator_test.go b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator_test.go index d3570cd1..78a86d09 100644 --- a/internal/modules/dnd/validate/enemyevents/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator_test.go @@ -28,7 +28,7 @@ func TestValidatorAcceptsUnicodeSubjectInCitedEvidence(t *testing.T) { } } -func TestValidatorDefersMalformedValuesAndBoundsWarnings(t *testing.T) { +func TestValidatorDefersMalformedValuesAndBoundsAdvisories(t *testing.T) { malformed := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}}} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("none"), Value: malformed}) if err != nil || !result.Approved || len(result.Diagnostics) != 0 { diff --git a/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator.go b/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator.go index 254adb32..f4395604 100644 --- a/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator.go @@ -45,19 +45,19 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq return contracts.ValidationResult{Approved: true}, nil } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, occurrence := range req.Value.Occurrences { citedText, err := resolver.CitedText(occurrence.SourceRefs) if err != nil || shared.ContainsTokenSequence(citedText, occurrence.Name) { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: ReasonCode, Message: fmt.Sprintf("item occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)), }) } - return diagnostics.DataQualityResult(warnings) + return diagnostics.DataQualityResult(findings) } func Spec() pipeline.ValidatorSpec { diff --git a/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator_test.go b/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator_test.go index 4e5ffd90..6cd02c17 100644 --- a/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator_test.go @@ -46,7 +46,7 @@ func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) { } } -func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) { +func TestValidatorBoundsAdvisoriesAndRegistersPolicy(t *testing.T) { count := contracts.MaxDiagnosticSamples + 5 occurrences := make([]dnd.ItemOccurrence, count) for index := range occurrences { diff --git a/internal/modules/dnd/validate/itemregistry/source_relatedness/validator.go b/internal/modules/dnd/validate/itemregistry/source_relatedness/validator.go index 389d32b1..7b3b9191 100644 --- a/internal/modules/dnd/validate/itemregistry/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/itemregistry/source_relatedness/validator.go @@ -50,17 +50,17 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[itemIndex] = citedText } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for itemIndex, item := range req.Value.Items { if shared.ContainsTokenSequence(citedTexts[itemIndex], item.Name) { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: fmt.Sprintf("items[%d]", itemIndex), ReasonCode: ReasonCode, Message: fmt.Sprintf("Item %s was not found in cited source text", diagnostics.Quote(item.Name)), }) } - return diagnostics.DataQualityResult(warnings) + return diagnostics.DataQualityResult(findings) } func Spec() pipeline.ValidatorSpec { diff --git a/internal/modules/dnd/validate/itemregistry/source_relatedness/validator_test.go b/internal/modules/dnd/validate/itemregistry/source_relatedness/validator_test.go index b7aff393..0b17971c 100644 --- a/internal/modules/dnd/validate/itemregistry/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/itemregistry/source_relatedness/validator_test.go @@ -16,17 +16,17 @@ func TestValidatorUsesOnlyCitedTranscriptText(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "They recover the star compass."}, {ID: 2, Text: "Unrelated text."}}} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemRegistry]{Source: doc, Value: value}) if err != nil || !result.Approved || len(result.Diagnostics) != 0 { - t.Fatalf("cited match = %#v, %v; want approval without warnings", result, err) + t.Fatalf("cited match = %#v, %v; want approval without advisories", result, err) } value.Items[0].Name = "Glossary Relic" before := value result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemRegistry]{Source: doc, References: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Glossary Relic")}}}}}, Value: value}) if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCode || !reflect.DeepEqual(value, before) { - t.Fatalf("reference-only match = %#v, %v; want advisory warning", result, err) + t.Fatalf("reference-only match = %#v, %v; want advisory", result, err) } } -func TestValidatorBoundsWarningsAndRegisters(t *testing.T) { +func TestValidatorBoundsAdvisoriesAndRegisters(t *testing.T) { items := make([]dnd.Item, contracts.MaxDiagnosticSamples+2) for index := range items { items[index] = dnd.Item{ID: "item", Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} diff --git a/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go index 2f447b5c..a4c7c2a1 100644 --- a/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go @@ -50,14 +50,14 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[index] = citedText } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, occurrence := range req.Value.Occurrences { if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) { continue } - warnings = append(warnings, diagnostics.Finding{Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: ReasonCode, Message: fmt.Sprintf("Location occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name))}) + findings = append(findings, diagnostics.Finding{Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: ReasonCode, Message: fmt.Sprintf("Location occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name))}) } - return diagnostics.DataQualityResult(warnings) + return diagnostics.DataQualityResult(findings) } func Spec() pipeline.ValidatorSpec { return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} diff --git a/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go index 26a933ea..fa9a8261 100644 --- a/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go @@ -28,7 +28,7 @@ func TestValidatorUsesOnlyCitedTranscriptEvidence(t *testing.T) { } } -func TestValidatorDefersUnreadableEvidenceAndBoundsWarnings(t *testing.T) { +func TestValidatorDefersUnreadableEvidenceAndBoundsAdvisories(t *testing.T) { invalid := occurrenceList("Missing") invalid.Occurrences[0].SourceRefs[0].StartUnitID = 99 invalid.Occurrences[0].SourceRefs[0].EndUnitID = 99 diff --git a/internal/modules/dnd/validate/locationregistry/source_relatedness/validator.go b/internal/modules/dnd/validate/locationregistry/source_relatedness/validator.go index 41d8fe52..39e3bd3e 100644 --- a/internal/modules/dnd/validate/locationregistry/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/locationregistry/source_relatedness/validator.go @@ -50,17 +50,17 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[locationIndex] = citedText } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for locationIndex, location := range req.Value.Locations { if shared.ContainsTokenSequence(citedTexts[locationIndex], location.Name) { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: fmt.Sprintf("locations[%d]", locationIndex), ReasonCode: ReasonCode, Message: fmt.Sprintf("Location %s was not found in cited source text", diagnostics.Quote(location.Name)), }) } - return diagnostics.DataQualityResult(warnings) + return diagnostics.DataQualityResult(findings) } func Spec() pipeline.ValidatorSpec { diff --git a/internal/modules/dnd/validate/locationregistry/source_relatedness/validator_test.go b/internal/modules/dnd/validate/locationregistry/source_relatedness/validator_test.go index 896d8959..a366c8f6 100644 --- a/internal/modules/dnd/validate/locationregistry/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/locationregistry/source_relatedness/validator_test.go @@ -17,14 +17,14 @@ func TestValidatorUsesOnlyCitedTranscriptText(t *testing.T) { doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party enters o’rin’s gate."}, {ID: 2, Kind: "message", Text: "Unrelated location."}}} result, err := New(Options{}).Validate(context.Background(), request(doc, value, contracts.ReferenceSet{})) if err != nil || !result.Approved || len(result.Diagnostics) != 0 { - t.Fatalf("cited match = %#v, %v; want approval without warnings", result, err) + t.Fatalf("cited match = %#v, %v; want approval without advisories", result, err) } value.Locations[0].Name = "Glossary Keep" before := value references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Glossary Keep")}}}}} result, err = New(Options{}).Validate(context.Background(), request(doc, value, references)) if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCode || !strings.Contains(result.Diagnostics[0].Samples[0].Message, "Glossary Keep") || !reflect.DeepEqual(value, before) { - t.Fatalf("reference-only match = %#v, %v; want advisory warning", result, err) + t.Fatalf("reference-only match = %#v, %v; want advisory", result, err) } } @@ -54,7 +54,7 @@ func TestValidatorRegisters(t *testing.T) { } } -func TestValidatorBoundsWarnings(t *testing.T) { +func TestValidatorBoundsAdvisories(t *testing.T) { locations := make([]dnd.Location, contracts.MaxDiagnosticSamples+2) for index := range locations { locations[index] = dnd.Location{ID: "candidate", Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} diff --git a/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator.go b/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator.go index 964db278..39623ed5 100644 --- a/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator.go @@ -50,18 +50,18 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[index] = citedText } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, occurrence := range req.Value.Occurrences { if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: ReasonCode, Message: fmt.Sprintf("NPC occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)), }) } - return diagnostics.DataQualityResult(warnings) + return diagnostics.DataQualityResult(findings) } func Spec() pipeline.ValidatorSpec { diff --git a/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator_test.go b/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator_test.go index a3f1feeb..89619ade 100644 --- a/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator_test.go @@ -50,7 +50,7 @@ func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) { } } -func TestValidatorBoundsWarnings(t *testing.T) { +func TestValidatorBoundsAdvisories(t *testing.T) { count := contracts.MaxDiagnosticSamples + 5 occurrences := make([]dnd.NPCOccurrence, count) for index := range occurrences { diff --git a/internal/modules/dnd/validate/npcregistry/source_relatedness/validator.go b/internal/modules/dnd/validate/npcregistry/source_relatedness/validator.go index b422856c..dc405b15 100644 --- a/internal/modules/dnd/validate/npcregistry/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/npcregistry/source_relatedness/validator.go @@ -49,18 +49,18 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[npcIndex] = citedText } - var warnings []diagnostics.Finding + var findings []diagnostics.Finding for npcIndex, npc := range req.Value.NPCs { if npcAppearsInCitedText(citedTexts[npcIndex], npc) { continue } - warnings = append(warnings, diagnostics.Finding{ + findings = append(findings, diagnostics.Finding{ Scope: fmt.Sprintf("npcs[%d]", npcIndex), ReasonCode: ReasonCode, Message: fmt.Sprintf("NPC %s was not found in cited source text", diagnostics.Quote(npc.Name)), }) } - return diagnostics.DataQualityResult(warnings) + return diagnostics.DataQualityResult(findings) } func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool { diff --git a/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator.go b/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator.go index 20159235..b9641551 100644 --- a/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator.go @@ -51,7 +51,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq if err != nil { return contracts.ValidationResult{Approved: true}, nil } - warnings := make([]diagnostics.Finding, 0) + findings := make([]diagnostics.Finding, 0) for index, scene := range req.Value.Scenes { citedText, err := resolver.CitedText([]source.SourceRef{scene.SourceRef}) if err != nil { @@ -59,13 +59,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTokens := tokenSet(citedText) if !hasGroundedToken(citedTokens, scene.Title) { - warnings = append(warnings, warning(index, "title")) + findings = append(findings, finding(index, "title")) } if !hasGroundedToken(citedTokens, scene.Summary) { - warnings = append(warnings, warning(index, "summary")) + findings = append(findings, finding(index, "summary")) } } - return diagnostics.DataQualityResult(warnings) + return diagnostics.DataQualityResult(findings) } func tokenSet(value string) map[string]struct{} { @@ -98,7 +98,7 @@ func significant(token string) bool { return !ignored } -func warning(index int, field string) diagnostics.Finding { +func finding(index int, field string) diagnostics.Finding { return diagnostics.Finding{ Scope: fmt.Sprintf("scenes[%d].%s", index, field), ReasonCode: ReasonCode, diff --git a/internal/modules/dnd/validate/spells/source_relatedness/validator.go b/internal/modules/dnd/validate/spells/source_relatedness/validator.go index 9e35b0d4..eab0a72c 100644 --- a/internal/modules/dnd/validate/spells/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/spells/source_relatedness/validator.go @@ -49,13 +49,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[spellIndex] = citedText } - var warnings []diagnostics.Finding + var findings []diagnostics.Finding for spellIndex, spell := range req.Value.SpellCasts { if !spellAppearsInCitedText(citedTexts[spellIndex], spell) { - warnings = append(warnings, diagnostics.Finding{Scope: fmt.Sprintf("spell_casts[%d]", spellIndex), ReasonCode: ReasonCode, Message: fmt.Sprintf("spell %s was not found in cited source text", diagnostics.Quote(strings.TrimSpace(spell.Spell)))}) + findings = append(findings, diagnostics.Finding{Scope: fmt.Sprintf("spell_casts[%d]", spellIndex), ReasonCode: ReasonCode, Message: fmt.Sprintf("spell %s was not found in cited source text", diagnostics.Quote(strings.TrimSpace(spell.Spell)))}) } } - return diagnostics.DataQualityResult(warnings) + return diagnostics.DataQualityResult(findings) } func spellAppearsInCitedText(citedText string, spell dnd.SpellCast) bool { name := strings.TrimSpace(spell.Spell) diff --git a/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go b/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go index a282e3b1..c6dde5f7 100644 --- a/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go @@ -10,7 +10,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" ) -func TestValidatorApprovesWithoutWarningWhenSpellAppearsInCitedText(t *testing.T) { +func TestValidatorApprovesWithoutAdvisoryWhenSpellAppearsInCitedText(t *testing.T) { result, err := New(Options{}).Validate(context.Background(), requestWithSpell(validDocument(), "Cure Wounds", 2)) if err != nil { t.Fatalf("Validate() error = %v, want nil", err) @@ -99,7 +99,7 @@ func TestValidatorIgnoresInvalidCitations(t *testing.T) { } } -func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) { +func TestValidatorApprovesEmptySpellListWithoutAdvisory(t *testing.T) { result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}}) if err != nil { t.Fatalf("Validate() error = %v, want nil", err) diff --git a/internal/modules/generic/output/json/encoder.go b/internal/modules/generic/output/json/encoder.go index 00b36235..20ff9079 100644 --- a/internal/modules/generic/output/json/encoder.go +++ b/internal/modules/generic/output/json/encoder.go @@ -281,6 +281,10 @@ type diagnosticsFile struct { } func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.OutputFile, error) { + diagnosticProjection, err := contracts.ProjectDiagnosticCollection(req.Diagnostics) + if err != nil { + return nil, encoderErrorf("diagnostics: %w", err) + } outputs := cloneNormalizeOutputs(req.NormalizeOutputs) sort.SliceStable(outputs, func(i, j int) bool { return outputs[i].LaneID < outputs[j].LaneID @@ -351,11 +355,11 @@ func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.Out if err != nil { return nil, err } - warningsOutput, err := jsonFile("warnings.json", newWarningsFile(req.Diagnostics)) + warningsOutput, err := jsonFile("warnings.json", newWarningsFile(diagnosticProjection)) if err != nil { return nil, err } - diagnosticsOutput, err := jsonFile("diagnostics.json", newDiagnosticsFile(req.Diagnostics)) + diagnosticsOutput, err := jsonFile("diagnostics.json", newDiagnosticsFile(req.Diagnostics, diagnosticProjection)) if err != nil { return nil, err } @@ -510,56 +514,26 @@ func cloneRejected(rejected []contracts.RejectedOutput) []contracts.RejectedOutp return append([]contracts.RejectedOutput(nil), rejected...) } -func newWarningsFile(collection contracts.DiagnosticCollection) warningsFile { - groups := diagnosticGroupsByDisposition(collection, contracts.DiagnosticDispositionWarning) +func newWarningsFile(projection contracts.DiagnosticProjection) warningsFile { return warningsFile{ SchemaVersion: warningsSchemaVersion, - GroupCount: len(groups), - OccurrenceCount: diagnosticOccurrenceCount(groups), - Groups: groups, + GroupCount: len(projection.Warnings), + OccurrenceCount: projection.WarningOccurrenceCount, + Groups: projection.Warnings, } } -func newDiagnosticsFile(collection contracts.DiagnosticCollection) diagnosticsFile { - groups := diagnosticGroupsExceptDisposition(collection, contracts.DiagnosticDispositionWarning) +func newDiagnosticsFile(collection contracts.DiagnosticCollection, projection contracts.DiagnosticProjection) diagnosticsFile { return diagnosticsFile{ SchemaVersion: diagnosticsSchemaVersion, - GroupCount: len(groups), - OccurrenceCount: diagnosticOccurrenceCount(groups) + collection.UnrepresentedOccurrenceCount, + GroupCount: len(projection.Diagnostics), + OccurrenceCount: projection.DiagnosticOccurrenceCount, Truncated: collection.Truncated, UnrepresentedOccurrenceCount: collection.UnrepresentedOccurrenceCount, - Groups: groups, + Groups: projection.Diagnostics, } } -func diagnosticGroupsByDisposition(collection contracts.DiagnosticCollection, disposition contracts.DiagnosticDisposition) []contracts.DiagnosticGroup { - groups := make([]contracts.DiagnosticGroup, 0) - for _, group := range collection.Groups { - if group.Disposition == disposition { - groups = append(groups, group) - } - } - return contracts.CloneDiagnosticCollection(contracts.DiagnosticCollection{Groups: groups}).Groups -} - -func diagnosticGroupsExceptDisposition(collection contracts.DiagnosticCollection, disposition contracts.DiagnosticDisposition) []contracts.DiagnosticGroup { - groups := make([]contracts.DiagnosticGroup, 0) - for _, group := range collection.Groups { - if group.Disposition != disposition { - groups = append(groups, group) - } - } - return contracts.CloneDiagnosticCollection(contracts.DiagnosticCollection{Groups: groups}).Groups -} - -func diagnosticOccurrenceCount(groups []contracts.DiagnosticGroup) int { - count := 0 - for _, group := range groups { - count += group.OccurrenceCount - } - return count -} - func encoderErrorf(format string, args ...any) error { return fmt.Errorf("json output encoder: "+format, args...) }