From 5d1399f9fb2287100d8bd092c256c44dc7456777 Mon Sep 17 00:00:00 2001 From: demiliani Date: Fri, 2 Oct 2026 10:21:05 +0200 Subject: [PATCH] Preserve literal strings in composed report acceptance --- docs/contributing.md | 3 +- docs/standalone-runner.md | 3 ++ tools/Test-ReviewContract.ps1 | 53 +++++++++++++++++++++++++++++++ tools/Validate-FindingsReport.ps1 | 24 ++++++++------ 4 files changed, 73 insertions(+), 10 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 51f6d0f..e0a51c3 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -154,7 +154,8 @@ not a deployable or compiled application. ## Before opening a PR From your BCQuality checkout, use the existing validators. The Python -validator needs Python and PyYAML; the fixture harness needs PowerShell 7. +validator needs Python and PyYAML; the fixture harness needs PowerShell 7.5 or later +so findings-report parsing preserves timestamp-shaped JSON strings verbatim. If PyYAML is not installed in your development environment, install it with `python -m pip install pyyaml`. diff --git a/docs/standalone-runner.md b/docs/standalone-runner.md index 74b2f8e..8980b03 100644 --- a/docs/standalone-runner.md +++ b/docs/standalone-runner.md @@ -78,6 +78,9 @@ its own task-level applicability and reports `not-applicable` or ## Composition acceptance +The findings-report validator requires PowerShell 7.5 or later to preserve +literal JSON strings with `ConvertFrom-Json -DateKind String`. + A report can be internally consistent while omitting a selected review. Bind the final acceptance gate to the host's selection, not just the returned reports. The private JSON input to `-ExpectedCompositionPath` follows the diff --git a/tools/Test-ReviewContract.ps1 b/tools/Test-ReviewContract.ps1 index a567e4c..db1c1b1 100644 --- a/tools/Test-ReviewContract.ps1 +++ b/tools/Test-ReviewContract.ps1 @@ -451,6 +451,59 @@ try { $removedFindingReport.findings = @() $removedFindingReport.summary.counts.minor = 0 Assert-CompositionReport $removedFindingReport '*SUPER_LEAF_CONTENT_MISMATCH*' + + $capturedCorrectionLeaf = $capturedFindingLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $capturedCorrectionLeaf.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'exit(1);' + $correctionComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $correctionComposition.acceptedResults = @(Save-AcceptedLeafReports @($capturedCorrectionLeaf, $completedSecurityLeaf)) + Set-Content -LiteralPath $compositionPath -Value ($correctionComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $capturedCorrectionReport = $capturedFindingReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $capturedCorrectionReport.'sub-results'[0] = $capturedCorrectionLeaf + $capturedCorrectionReport.findings[0] | Add-Member -NotePropertyName 'suggested-code' -NotePropertyValue 'exit(1);' + Assert-CompositionReport $capturedCorrectionReport + $correctionCapturePath = $correctionComposition.acceptedResults[0].reportPath + $immutableCorrectionCapture = [IO.File]::ReadAllText($correctionCapturePath) + foreach ($codePoint in @(0x0000, 0x00AD, 0x200B, 0xFEFF)) { + $alteredCorrectionReport = $capturedCorrectionReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $alteredCorrection = 'ex' + [char]$codePoint + 'it(1);' + $alteredCorrectionReport.'sub-results'[0].findings[0].'suggested-code' = $alteredCorrection + $alteredCorrectionReport.findings[0].'suggested-code' = $alteredCorrection + Assert-CompositionReport $alteredCorrectionReport '*SUPER_LEAF_CONTENT_MISMATCH*' + $rolledOnlyCorrectionReport = $capturedCorrectionReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $rolledOnlyCorrectionReport.findings[0].'suggested-code' = $alteredCorrection + Assert-CompositionReport $rolledOnlyCorrectionReport '*SUPER_FINDING_MISMATCH*' + Assert-True ([string]::Equals([IO.File]::ReadAllText($correctionCapturePath), $immutableCorrectionCapture, [StringComparison]::Ordinal)) ` + 'rejecting altered corrections leaves the immutable host capture unchanged' + } + $timestampReason = '2026-10-02T09:00:00Z' + $timestampLeaf = $completedLeaf | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $timestampLeaf | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue $timestampReason + $timestampComposition = $expectedComposition | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $timestampComposition.acceptedResults = @(Save-AcceptedLeafReports @($timestampLeaf, $completedSecurityLeaf)) + Set-Content -LiteralPath $compositionPath -Value ($timestampComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $timestampReport = $validSuperReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $timestampReport.'sub-results'[0] = $timestampLeaf + foreach ($alteredTimestamp in @('2026-10-02T09:00:00.000Z', '2026-10-02T09:00:00+00:00')) { + $timestampLeaf.'outcome-reason' = $alteredTimestamp + Assert-CompositionReport $timestampReport '*SUPER_LEAF_CONTENT_MISMATCH*' + } + $timestampLeaf.'outcome-reason' = $timestampReason + Set-Content -LiteralPath $reportPath -Value ($timestampReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedTimestampReport = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SkillKind super ` + -ExpectedCompositionPath $compositionPath + $acceptedReason = $acceptedTimestampReport.report.'sub-results'[0].'outcome-reason' + Assert-True ($acceptedReason -is [string] -and [string]::Equals($acceptedReason, $timestampReason, [StringComparison]::Ordinal)) ` + 'accepted timestamp-shaped JSON text remains the original literal string' + $normalizedTimestampReport = $validReport | ConvertTo-Json -Depth 20 | ConvertFrom-Json + $normalizedTimestampReport | Add-Member -NotePropertyName 'outcome-reason' -NotePropertyValue '2026-10-02T09:00:00.000Z' + $normalizedTimestampReport.findings[0].location.range.'start-line' = 1 + Set-Content -LiteralPath $reportPath -Value ($normalizedTimestampReport | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM + $acceptedNormalizedTimestamp = & $validator -ReportPath $reportPath -BCQualityRoot $Root -SourceRoot $tmp ` + -SourcePaths $sourcePath -RetrievedArticlePaths $articlePath -AllowBoundedNormalization + Assert-True $acceptedNormalizedTimestamp.normalized 'bounded normalization still applies to an eligible range' + Assert-True ($acceptedNormalizedTimestamp.report.'outcome-reason' -is [string] -and + [string]::Equals($acceptedNormalizedTimestamp.report.'outcome-reason', $normalizedTimestampReport.'outcome-reason', [StringComparison]::Ordinal)) ` + 'bounded normalization preserves unrelated timestamp-shaped text exactly' Set-Content -LiteralPath $compositionPath -Value ($expectedComposition | ConvertTo-Json -Depth 20) -Encoding utf8NoBOM foreach ($case in @( diff --git a/tools/Validate-FindingsReport.ps1 b/tools/Validate-FindingsReport.ps1 index d5e5dce..b3c41b3 100644 --- a/tools/Validate-FindingsReport.ps1 +++ b/tools/Validate-FindingsReport.ps1 @@ -1,3 +1,4 @@ +#Requires -Version 7.5 <# .SYNOPSIS Validates a BCQuality findings-report against its structural and semantic contract. @@ -29,7 +30,7 @@ try { if (-not ($raw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) { throw 'Report does not satisfy schemas/findings-report.schema.json.' } - $report = $raw | ConvertFrom-Json -Depth 100 + $report = $raw | ConvertFrom-Json -Depth 100 -DateKind String } catch { throw "Invalid findings-report JSON or schema: $($_.Exception.Message)" @@ -83,7 +84,7 @@ if ($ExpectedCompositionPath) { if (-not ($compositionRaw | Test-Json -Schema $compositionSchema -ErrorAction Stop)) { throw 'Expected composition does not satisfy its input contract.' } - $expectedComposition = $compositionRaw | ConvertFrom-Json -Depth 100 + $expectedComposition = $compositionRaw | ConvertFrom-Json -Depth 100 -DateKind String $expectedIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) foreach ($leaf in @($expectedComposition.subSkills)) { if (-not $expectedIds.Add([string]$leaf.id)) { @@ -114,7 +115,7 @@ if ($ExpectedCompositionPath) { if (-not ($acceptedRaw | Test-Json -SchemaFile $schemaPath -ErrorAction Stop)) { throw "Accepted result '$($accepted.id)' does not satisfy the report schema." } - $acceptedReport = $acceptedRaw | ConvertFrom-Json -Depth 100 + $acceptedReport = $acceptedRaw | ConvertFrom-Json -Depth 100 -DateKind String if ($acceptedReport.skill.id -cne $accepted.id -or $acceptedReport.skill.version -ne $accepted.version -or $acceptedReport.PSObject.Properties.Name -ccontains 'sub-results' -or $acceptedReport.PSObject.Properties.Name -ccontains 'skipped-sub-skills') { @@ -173,6 +174,10 @@ function Test-JsonContentEqual { } return $true } + if ($First -is [string] -or $Second -is [string]) { + return $First -is [string] -and $Second -is [string] -and + [string]::Equals($First, $Second, [StringComparison]::Ordinal) + } return $First.GetType() -eq $Second.GetType() -and $First -ceq $Second } @@ -271,9 +276,10 @@ function Get-SemanticErrors { $firstHasCode = Test-HasProperty $First 'suggested-code' $secondHasCode = Test-HasProperty $Second 'suggested-code' if ($firstHasCode -or $secondHasCode) { - return $firstHasCode -and $secondHasCode -and $First.'suggested-code' -ceq $Second.'suggested-code' + return $firstHasCode -and $secondHasCode -and + [string]::Equals($First.'suggested-code', $Second.'suggested-code', [StringComparison]::Ordinal) } - return $First.message -ceq $Second.message + return [string]::Equals($First.message, $Second.message, [StringComparison]::Ordinal) } function Test-ReferencesInclude { @@ -314,7 +320,7 @@ function Get-SemanticErrors { $RolledFinding.id -cne $expectedId -or $RolledFinding.severity -cne $LeafFinding.severity -or $RolledFinding.confidence -cne $LeafFinding.confidence -or - $RolledFinding.message -cne $LeafFinding.message -or + -not [string]::Equals($RolledFinding.message, $LeafFinding.message, [StringComparison]::Ordinal) -or $rolledReferences.Count -ne $leafReferences.Count -or -not (Test-ReferencesInclude $rolledReferences $leafReferences)) { return $false @@ -323,7 +329,7 @@ function Get-SemanticErrors { $rolledHasProperty = Test-HasProperty $RolledFinding $name $leafHasProperty = Test-HasProperty $LeafFinding $name if ($rolledHasProperty -ne $leafHasProperty -or - ($rolledHasProperty -and $RolledFinding.$name -cne $LeafFinding.$name)) { + ($rolledHasProperty -and -not [string]::Equals($RolledFinding.$name, $LeafFinding.$name, [StringComparison]::Ordinal))) { return $false } } @@ -339,7 +345,7 @@ function Get-SemanticErrors { $sameCorrection = Test-SameCorrection $RolledFinding $LeafFinding $correctionsConflict = (Test-HasProperty $RolledFinding 'suggested-code') -and (Test-HasProperty $LeafFinding 'suggested-code') -and - $RolledFinding.'suggested-code' -cne $LeafFinding.'suggested-code' + -not [string]::Equals($RolledFinding.'suggested-code', $LeafFinding.'suggested-code', [StringComparison]::Ordinal) $explicitCrossRuleMerge = $leafReferences.Count -and $rolledReferences.Count -gt $leafReferences.Count -and -not $correctionsConflict -and @@ -718,7 +724,7 @@ if ($errors.Count -and $AllowBoundedNormalization) { $otherErrors = @($errors | Where-Object Code -CNE 'RANGE_START_MISMATCH') $rangeErrors = @($errors | Where-Object Code -CEQ 'RANGE_START_MISMATCH') if (-not $otherErrors.Count -and $rangeErrors.Count) { - $candidate = $report | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100 + $candidate = $report | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100 -DateKind String $eligible = $true foreach ($finding in @($candidate.findings)) { if (-not (Test-HasProperty $finding 'location') -or