Add production validation reports
This commit is contained in:
parent
bc54528353
commit
0e92e31d89
@ -35,6 +35,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
examples.
|
||||
- Add deliberately failing YAML fixtures that demonstrate exact validation
|
||||
diagnostics, strict failures, and non-strict warning returns.
|
||||
- Add non-throwing single-source and batch validation reports with exact parse
|
||||
and validation diagnostics, optional warning-as-failure status, and Markdown
|
||||
rendering for application-owned report files.
|
||||
|
||||
### Changed
|
||||
|
||||
@ -50,6 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
tagged parser and validator explicitly.
|
||||
- Preserve anchors and tags on mapping values and sequence items whose actual
|
||||
node starts on the following indented line.
|
||||
- Harden real-world parsing for empty documents, indented root fragments,
|
||||
JSON-style flow mapping pairs, and tab-indented files encountered in corpus
|
||||
validation.
|
||||
|
||||
## [0.1.0] - 2026-06-05
|
||||
|
||||
|
||||
13
README.md
13
README.md
@ -204,6 +204,19 @@ errors still throw.
|
||||
let issues = try PureYAML.validate(document, strict: false)
|
||||
```
|
||||
|
||||
For production-style validation over many YAML inputs, use the non-throwing
|
||||
report API. It validates every source, turns parse failures into diagnostics,
|
||||
and can render Markdown that a consuming tool writes to disk:
|
||||
|
||||
```swift
|
||||
let report = PureYAML.validationReports([
|
||||
.init(name: "valid.yaml", yaml: "title: Ready"),
|
||||
.init(name: "broken.yaml", yaml: "title: \"open"),
|
||||
])
|
||||
|
||||
let markdown = report.markdownDescription(title: "YAML Validation")
|
||||
```
|
||||
|
||||
Custom validation rules can be layered onto the default validator or attached to
|
||||
a blank validator when callers want only project-specific checks. Validation
|
||||
tests pin exact issue paths, descriptions, severity handling, rule traversal
|
||||
|
||||
@ -37,7 +37,8 @@ extension PureYAML.Parsing.Scanner {
|
||||
width += 1
|
||||
state.reader.advance()
|
||||
} else if character == "\t" {
|
||||
throw PureYAML.Parsing.ParseError.tabIndentation(line: state.reader.mark.line)
|
||||
width = nextTabStop(after: width)
|
||||
state.reader.advance()
|
||||
} else {
|
||||
return width
|
||||
}
|
||||
@ -45,6 +46,10 @@ extension PureYAML.Parsing.Scanner {
|
||||
return width
|
||||
}
|
||||
|
||||
func nextTabStop(after width: Int) -> Int {
|
||||
((width / 4) + 1) * 4
|
||||
}
|
||||
|
||||
func shouldProcessIndentation(_ state: State) -> Bool {
|
||||
guard !state.reader.isAtEnd, !isLineBreak(state.reader.peek()) else {
|
||||
return false
|
||||
|
||||
@ -73,11 +73,33 @@ extension PureYAML.Parsing.TokenEventParser {
|
||||
_ = cursor.advance()
|
||||
continue
|
||||
}
|
||||
endMark = try parseMappingPair().endMark
|
||||
endMark = try parseFlowMappingPair().endMark
|
||||
if cursor.current?.kind.isFlowTerminator == false {
|
||||
throw unexpectedToken(expected: "flow entry or flow mapping end")
|
||||
}
|
||||
}
|
||||
throw unexpectedToken(expected: "flow mapping end")
|
||||
}
|
||||
|
||||
mutating func parseFlowMappingPair() throws -> PureYAML.Parsing.NodeResult {
|
||||
if cursor.current?.kind.isMappingKey == true {
|
||||
return try parseMappingPair()
|
||||
}
|
||||
|
||||
_ = try parseNode()
|
||||
let valueIndicator = try expect("mapping value") { kind in
|
||||
if case .mappingValue = kind {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let properties = consumeProperties()
|
||||
guard properties != .none || cursor.current?.kind.isFlowTerminator == false else {
|
||||
let mark = valueIndicator.endMark
|
||||
events.append(.scalar(value: "", anchor: nil, tag: nil, style: .plain, mark: mark))
|
||||
return PureYAML.Parsing.NodeResult(kind: .scalar, endMark: mark)
|
||||
}
|
||||
return try parseNode(properties: properties)
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,8 +27,11 @@ extension PureYAML.Parsing {
|
||||
try parseDocument()
|
||||
didParseDocument = true
|
||||
}
|
||||
guard didParseDocument else {
|
||||
throw ParseError.emptyDocument
|
||||
if !didParseDocument {
|
||||
let mark = cursor.current?.mark ?? streamStart.endMark
|
||||
events.append(.documentStart(mark: mark))
|
||||
appendEmptyDocument(at: mark)
|
||||
events.append(.documentEnd(mark: mark))
|
||||
}
|
||||
let streamEnd = try expect("stream end") { kind in
|
||||
if case .streamEnd = kind {
|
||||
@ -60,8 +63,7 @@ extension PureYAML.Parsing.TokenEventParser {
|
||||
return
|
||||
}
|
||||
|
||||
let result = try parseNode()
|
||||
var endMark = result.endMark
|
||||
var endMark = try parseDocumentRootNode().endMark
|
||||
while cursor.current?.kind.isDedent == true {
|
||||
let dedent = try expect("dedent") { $0.isDedent }
|
||||
endMark = dedent.endMark
|
||||
@ -81,6 +83,18 @@ extension PureYAML.Parsing.TokenEventParser {
|
||||
events.append(.documentEnd(mark: endMark))
|
||||
}
|
||||
|
||||
mutating func parseDocumentRootNode() throws -> PureYAML.Parsing.NodeResult {
|
||||
guard cursor.current?.kind.isIndent == true else {
|
||||
return try parseNode()
|
||||
}
|
||||
_ = try expect("indent") { $0.isIndent }
|
||||
let result = try parseNode()
|
||||
if cursor.current?.kind.isDedent == true {
|
||||
_ = try expect("dedent") { $0.isDedent }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
mutating func parseDocumentStartMark() -> PureYAML.Parsing.Mark {
|
||||
guard cursor.current?.kind.isDocumentStart == true else {
|
||||
return cursor.current?.mark ?? cursor.previous?.endMark ?? .start
|
||||
|
||||
56
Sources/Validation/Validation.Diagnostic.swift
Normal file
56
Sources/Validation/Validation.Diagnostic.swift
Normal file
@ -0,0 +1,56 @@
|
||||
public extension PureYAML.Validation {
|
||||
enum DiagnosticKind: Equatable, Sendable, CustomStringConvertible {
|
||||
case parse
|
||||
case validation
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .parse:
|
||||
"parse"
|
||||
case .validation:
|
||||
"validation"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parse or validation diagnostic suitable for batch validation reports.
|
||||
struct Diagnostic: Equatable, Sendable, CustomStringConvertible {
|
||||
public var kind: DiagnosticKind
|
||||
public var severity: Severity
|
||||
public var file: String?
|
||||
public var documentIndex: Int?
|
||||
public var path: Path?
|
||||
public var reason: String
|
||||
|
||||
public init(
|
||||
kind: DiagnosticKind,
|
||||
severity: Severity,
|
||||
file: String? = nil,
|
||||
documentIndex: Int? = nil,
|
||||
path: Path? = nil,
|
||||
reason: String,
|
||||
) {
|
||||
self.kind = kind
|
||||
self.severity = severity
|
||||
self.file = file
|
||||
self.documentIndex = documentIndex
|
||||
self.path = path
|
||||
self.reason = reason
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
var parts: [String] = []
|
||||
if let file {
|
||||
parts.append(file)
|
||||
}
|
||||
if let documentIndex {
|
||||
parts.append("document[\(documentIndex)]")
|
||||
}
|
||||
if let path {
|
||||
parts.append(path.isRoot ? "root" : path.description)
|
||||
}
|
||||
let location = parts.isEmpty ? "input" : parts.joined(separator: ": ")
|
||||
return "\(location): \(severity): \(kind): \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
187
Sources/Validation/Validation.Report.swift
Normal file
187
Sources/Validation/Validation.Report.swift
Normal file
@ -0,0 +1,187 @@
|
||||
public extension PureYAML.Validation {
|
||||
/// Named YAML source used for batch validation without coupling PureYAML to file IO.
|
||||
struct Source: Equatable, Sendable {
|
||||
public var name: String
|
||||
public var yaml: String
|
||||
|
||||
public init(name: String, yaml: String) {
|
||||
self.name = name
|
||||
self.yaml = yaml
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-throwing validation report for one YAML input.
|
||||
struct Report: Equatable, Sendable, CustomStringConvertible {
|
||||
public var diagnostics: [Diagnostic]
|
||||
|
||||
public init(_ diagnostics: [Diagnostic] = []) {
|
||||
self.diagnostics = diagnostics
|
||||
}
|
||||
|
||||
public var isEmpty: Bool {
|
||||
diagnostics.isEmpty
|
||||
}
|
||||
|
||||
public var errors: [Diagnostic] {
|
||||
diagnostics.filter { $0.severity == .error }
|
||||
}
|
||||
|
||||
public var warnings: [Diagnostic] {
|
||||
diagnostics.filter { $0.severity == .warning }
|
||||
}
|
||||
|
||||
public var isValid: Bool {
|
||||
errors.isEmpty
|
||||
}
|
||||
|
||||
public func containsFailures(failOnWarnings: Bool = false) -> Bool {
|
||||
failOnWarnings ? !diagnostics.isEmpty : !errors.isEmpty
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
diagnostics.map(\.description).joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Validation result for one named source in a batch.
|
||||
struct SourceReport: Equatable, Sendable {
|
||||
public var name: String
|
||||
public var report: Report
|
||||
public var invalid: Bool
|
||||
|
||||
public init(name: String, report: Report, invalid: Bool) {
|
||||
self.name = name
|
||||
self.report = report
|
||||
self.invalid = invalid
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-throwing aggregate report for validating many YAML inputs.
|
||||
struct BatchReport: Equatable, Sendable, CustomStringConvertible {
|
||||
public var sourceReports: [SourceReport]
|
||||
public var failOnWarnings: Bool
|
||||
|
||||
public init(
|
||||
sourceReports: [SourceReport],
|
||||
failOnWarnings: Bool = false,
|
||||
) {
|
||||
self.sourceReports = sourceReports
|
||||
self.failOnWarnings = failOnWarnings
|
||||
}
|
||||
|
||||
public var fileCount: Int {
|
||||
sourceReports.count
|
||||
}
|
||||
|
||||
public var validCount: Int {
|
||||
sourceReports.count(where: { !$0.invalid })
|
||||
}
|
||||
|
||||
public var invalidCount: Int {
|
||||
sourceReports.count(where: { $0.invalid })
|
||||
}
|
||||
|
||||
public var diagnosticCount: Int {
|
||||
sourceReports.reduce(0) { count, sourceReport in
|
||||
count + sourceReport.report.diagnostics.count
|
||||
}
|
||||
}
|
||||
|
||||
public var isValid: Bool {
|
||||
invalidCount == 0
|
||||
}
|
||||
|
||||
public var diagnostics: [Diagnostic] {
|
||||
sourceReports.flatMap(\.report.diagnostics)
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
diagnostics.map(\.description).joined(separator: "\n")
|
||||
}
|
||||
|
||||
public func markdownDescription(
|
||||
title: String = "PureYAML Validation Report",
|
||||
) -> String {
|
||||
var lines: [String] = [
|
||||
"# \(title)",
|
||||
"",
|
||||
"- Files: \(fileCount)",
|
||||
"- Valid: \(validCount)",
|
||||
"- Invalid: \(invalidCount)",
|
||||
"- Diagnostics: \(diagnosticCount)",
|
||||
"- Warnings fail: \(failOnWarnings ? "yes" : "no")",
|
||||
"",
|
||||
]
|
||||
|
||||
for sourceReport in sourceReports {
|
||||
lines.append("## \(sourceReport.name)")
|
||||
lines.append("")
|
||||
lines.append("Status: \(sourceReport.invalid ? "invalid" : "valid")")
|
||||
lines.append("")
|
||||
|
||||
if sourceReport.report.isEmpty {
|
||||
lines.append("No diagnostics.")
|
||||
} else {
|
||||
lines.append("```text")
|
||||
lines.append(sourceReport.report.description)
|
||||
lines.append("```")
|
||||
}
|
||||
|
||||
lines.append("")
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureYAML {
|
||||
/// Parses and validates YAML without throwing, returning parse and validation diagnostics.
|
||||
static func validationReport(
|
||||
_ yaml: String,
|
||||
file: String? = nil,
|
||||
using validator: Validation.Validator = .init(),
|
||||
) -> Validation.Report {
|
||||
do {
|
||||
let documents = try parseStream(yaml)
|
||||
let result = validator.collect(documents)
|
||||
let diagnostics = result.issues.map { streamIssue in
|
||||
Validation.Diagnostic(
|
||||
kind: .validation,
|
||||
severity: streamIssue.issue.severity,
|
||||
file: file,
|
||||
documentIndex: streamIssue.documentIndex,
|
||||
path: streamIssue.issue.path,
|
||||
reason: streamIssue.issue.reason,
|
||||
)
|
||||
}
|
||||
return Validation.Report(diagnostics)
|
||||
} catch {
|
||||
return Validation.Report([
|
||||
Validation.Diagnostic(
|
||||
kind: .parse,
|
||||
severity: .error,
|
||||
file: file,
|
||||
reason: String(describing: error),
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates many YAML inputs without throwing or stopping at the first failure.
|
||||
static func validationReports(
|
||||
_ sources: [Validation.Source],
|
||||
using validator: Validation.Validator = .init(),
|
||||
failOnWarnings: Bool = false,
|
||||
) -> Validation.BatchReport {
|
||||
let reports = sources.map { source in
|
||||
let report = validationReport(source.yaml, file: source.name, using: validator)
|
||||
return Validation.SourceReport(
|
||||
name: source.name,
|
||||
report: report,
|
||||
invalid: report.containsFailures(failOnWarnings: failOnWarnings),
|
||||
)
|
||||
}
|
||||
return Validation.BatchReport(sourceReports: reports, failOnWarnings: failOnWarnings)
|
||||
}
|
||||
}
|
||||
@ -5,9 +5,6 @@ import Testing
|
||||
struct ParsingErrorTests {
|
||||
@Test("Reports exact parser errors")
|
||||
func test_errorReporting() {
|
||||
expectParseError("", .emptyDocument)
|
||||
expectParseError("# comment only", .emptyDocument)
|
||||
expectParseError("\tkey: value", .tabIndentation(line: 1))
|
||||
expectParseError("root:\n child: yes\n wrong: no", .unexpectedIndentation(line: 3))
|
||||
expectParseError("- one\nkey: value", .mixedCollectionStyles(line: 2))
|
||||
expectParseError("root:\n child: value\n dangling", .unexpectedToken(
|
||||
@ -28,6 +25,25 @@ struct ParsingErrorTests {
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Parses empty and comment-only documents as null")
|
||||
func test_emptyAndCommentOnlyDocumentsParseAsNull() throws {
|
||||
#expect(try PureYAML.parse("") == .null)
|
||||
#expect(try PureYAML.parse("# comment only") == .null)
|
||||
#expect(try PureYAML.parse(" \n# comment only\n") == .null)
|
||||
}
|
||||
|
||||
@Test("Parses tab indentation with permissive tab stops")
|
||||
func test_tabIndentationUsesPermissiveTabStops() throws {
|
||||
let root = try requireMapping(PureYAML.parse(
|
||||
"""
|
||||
root:
|
||||
\tchild: value
|
||||
""",
|
||||
))
|
||||
|
||||
#expect(root?.mapping("root")?["child"] == .string("value"))
|
||||
}
|
||||
|
||||
@Test("Parser errors describe returned failures")
|
||||
func test_errorDescriptions() {
|
||||
#expect(PureYAML.Parsing.ParseError.emptyDocument.description == "document is empty")
|
||||
|
||||
@ -105,6 +105,32 @@ struct ParsingEventTests {
|
||||
#expect(!descriptions.contains { $0.contains("it''s") })
|
||||
}
|
||||
|
||||
@Test("Parses JSON-style flow mapping pairs")
|
||||
func test_jsonStyleFlowMappingPairs() throws {
|
||||
let root = try requireMapping(PureYAML.parse(
|
||||
"""
|
||||
value:
|
||||
{
|
||||
"version" : "7",
|
||||
"fields" : [
|
||||
{
|
||||
"key" : "productType",
|
||||
"required" : true
|
||||
}
|
||||
]
|
||||
}
|
||||
""",
|
||||
))
|
||||
|
||||
let value = root?.mapping("value")
|
||||
let field = value?.sequence("fields")?.first?.mapping
|
||||
|
||||
#expect(value?["version"] == .string("7"))
|
||||
#expect(field?["key"] == .string("productType"))
|
||||
#expect(field?["required"] == .bool(true))
|
||||
#expect(value?["missing"] == nil)
|
||||
}
|
||||
|
||||
@Test("Emits golden events for block scalar headers")
|
||||
func test_blockScalarHeaders() throws {
|
||||
let events = try PureYAML.Parsing.Parser().parseEvents(
|
||||
@ -141,9 +167,6 @@ struct ParsingEventTests {
|
||||
|
||||
@Test("Reports parse-event errors")
|
||||
func test_errorReporting() {
|
||||
expectError(PureYAML.Parsing.ParseError.emptyDocument) {
|
||||
try PureYAML.Parsing.Parser().parseEvents("")
|
||||
}
|
||||
expectError(PureYAML.Parsing.ParseError.unexpectedIndentation(line: 3)) {
|
||||
try PureYAML.Parsing.Parser().parseEvents("root:\n child: yes\n wrong: no")
|
||||
}
|
||||
@ -166,6 +189,19 @@ struct ParsingEventTests {
|
||||
description: "expected flow entry or flow sequence end at line 1, column 17, found streamEnd",
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Emits null-document events for empty input")
|
||||
func test_emptyInputEmitsNullDocumentEvents() throws {
|
||||
let events = try PureYAML.Parsing.Parser().parseEvents("")
|
||||
|
||||
#expect(events.map(\.description) == [
|
||||
"streamStart @1:1@0",
|
||||
"documentStart @1:1@0",
|
||||
"scalar value=\"\" anchor=- tag=- style=plain @1:1@0",
|
||||
"documentEnd @1:1@0",
|
||||
"streamEnd @1:1@0",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func expectParseEventError(
|
||||
|
||||
@ -3,6 +3,38 @@ import Testing
|
||||
|
||||
@Suite("Parsing Indentation Regressions")
|
||||
struct ParsingIndentationRegressionTests {
|
||||
@Test("Parses indented root mapping fragments")
|
||||
func test_indentedRootMappingFragments() throws {
|
||||
let root = try requireMapping(PureYAML.parse(
|
||||
"""
|
||||
title: OpenAPI Generation Demo
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: API Support
|
||||
email: support@example.com
|
||||
""",
|
||||
))
|
||||
|
||||
#expect(root?["title"] == .string("OpenAPI Generation Demo"))
|
||||
#expect(root?["version"] == .string("1.0.0"))
|
||||
#expect(root?.mapping("contact")?["name"] == .string("API Support"))
|
||||
#expect(root?.mapping("contact")?["email"] == .string("support@example.com"))
|
||||
#expect(root?["missing"] == nil)
|
||||
}
|
||||
|
||||
@Test("Parses indented root mapping fragments with leading blank lines")
|
||||
func test_indentedRootMappingFragmentsWithLeadingBlankLines() throws {
|
||||
let root = try requireMapping(PureYAML.parse(
|
||||
"""
|
||||
|
||||
securitySchemes:
|
||||
""",
|
||||
))
|
||||
|
||||
#expect(root?["securitySchemes"] == .null)
|
||||
#expect(root?["missing"] == nil)
|
||||
}
|
||||
|
||||
@Test("Parses compact sequence item mappings without swallowing lower-column siblings")
|
||||
func test_compactSequenceItemMappingsDoNotSwallowLowerColumnSiblings() throws {
|
||||
let root = try requireMapping(PureYAML.parse(
|
||||
|
||||
@ -238,7 +238,6 @@ struct ScannerTests {
|
||||
|
||||
@Test("Reports scanner errors")
|
||||
func test_errorReporting() {
|
||||
expectScannerError("\tname: value", .tabIndentation(line: 1))
|
||||
expectScannerError("root:\n child: yes\n wrong: no", .unexpectedIndentation(line: 3))
|
||||
expectScannerError("name: \"open", .unterminatedQuotedString(line: 1))
|
||||
expectScannerError("name: 'open", .unterminatedQuotedString(line: 1))
|
||||
|
||||
245
Tests/ValidationReportTests.swift
Normal file
245
Tests/ValidationReportTests.swift
Normal file
@ -0,0 +1,245 @@
|
||||
import Foundation
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
@Suite("Validation Reports")
|
||||
struct ValidationReportTests {
|
||||
@Test("Valid YAML returns an empty report")
|
||||
func test_validYAMLReturnsEmptyReport() {
|
||||
let report = PureYAML.validationReport("title: Ready", file: "valid.yaml")
|
||||
|
||||
#expect(report.isEmpty)
|
||||
#expect(report.isValid)
|
||||
#expect(report.description == "")
|
||||
}
|
||||
|
||||
@Test("Parse failures return file-scoped diagnostics")
|
||||
func test_parseFailuresReturnFileScopedDiagnostics() {
|
||||
let report = PureYAML.validationReport("title: \"open", file: "broken.yaml")
|
||||
|
||||
#expect(report.diagnostics == [
|
||||
.init(
|
||||
kind: .parse,
|
||||
severity: .error,
|
||||
file: "broken.yaml",
|
||||
reason: "unterminated quoted string at line 1",
|
||||
),
|
||||
])
|
||||
#expect(!report.isValid)
|
||||
#expect(report.description == "broken.yaml: error: parse: unterminated quoted string at line 1")
|
||||
}
|
||||
|
||||
@Test("Duplicate keys return document and path diagnostics")
|
||||
func test_duplicateKeysReturnDocumentAndPathDiagnostics() {
|
||||
let report = PureYAML.validationReport(
|
||||
"""
|
||||
title: First
|
||||
title: Second
|
||||
""",
|
||||
file: "duplicates.yaml",
|
||||
)
|
||||
|
||||
#expect(report.diagnostics == [
|
||||
.init(
|
||||
kind: .validation,
|
||||
severity: .error,
|
||||
file: "duplicates.yaml",
|
||||
documentIndex: 0,
|
||||
path: .init([.key("title")]),
|
||||
reason: "Duplicate mapping key 'title'",
|
||||
),
|
||||
])
|
||||
#expect(report.description == "duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'")
|
||||
}
|
||||
|
||||
@Test("Warning reports stay visible without making the report invalid")
|
||||
func test_warningReportsStayVisibleWithoutMakingReportInvalid() {
|
||||
let validator = PureYAML.Validation.Validator.blank.validating(
|
||||
PureYAML.Validation.Rule(
|
||||
description: "Mode is not legacy",
|
||||
severity: .warning,
|
||||
check: { context in context.subject != .string("legacy") },
|
||||
when: { context in context.path.components.last == .key("mode") },
|
||||
),
|
||||
)
|
||||
|
||||
let report = PureYAML.validationReport("mode: legacy", file: "warnings.yaml", using: validator)
|
||||
|
||||
#expect(report.diagnostics == [
|
||||
.init(
|
||||
kind: .validation,
|
||||
severity: .warning,
|
||||
file: "warnings.yaml",
|
||||
documentIndex: 0,
|
||||
path: .init([.key("mode")]),
|
||||
reason: "Failed to satisfy: Mode is not legacy",
|
||||
),
|
||||
])
|
||||
#expect(report.isValid)
|
||||
#expect(report.warnings.count == 1)
|
||||
#expect(report.errors.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Batch reports collect every source without stopping at the first failure")
|
||||
func test_batchReportsCollectEverySourceWithoutStoppingAtFirstFailure() {
|
||||
let validator = PureYAML.Validation.Validator().validating(
|
||||
PureYAML.Validation.Rule(
|
||||
description: "Mode is not legacy",
|
||||
severity: .warning,
|
||||
check: { context in context.subject != .string("legacy") },
|
||||
when: { context in context.path.components.last == .key("mode") },
|
||||
),
|
||||
)
|
||||
|
||||
let report = PureYAML.validationReports(
|
||||
[
|
||||
.init(name: "valid.yaml", yaml: "title: Ready"),
|
||||
.init(name: "broken.yaml", yaml: "title: \"open"),
|
||||
.init(
|
||||
name: "duplicates.yaml",
|
||||
yaml: """
|
||||
title: First
|
||||
title: Second
|
||||
""",
|
||||
),
|
||||
.init(name: "warnings.yaml", yaml: "mode: legacy"),
|
||||
],
|
||||
using: validator,
|
||||
)
|
||||
|
||||
#expect(report.fileCount == 4)
|
||||
#expect(report.validCount == 2)
|
||||
#expect(report.invalidCount == 2)
|
||||
#expect(report.diagnosticCount == 3)
|
||||
#expect(!report.isValid)
|
||||
#expect(report.description == [
|
||||
"broken.yaml: error: parse: unterminated quoted string at line 1",
|
||||
"duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'",
|
||||
"warnings.yaml: document[0]: $.mode: warning: validation: Failed to satisfy: Mode is not legacy",
|
||||
].joined(separator: "\n"))
|
||||
#expect(report.sourceReports.map(\.name) == [
|
||||
"valid.yaml",
|
||||
"broken.yaml",
|
||||
"duplicates.yaml",
|
||||
"warnings.yaml",
|
||||
])
|
||||
#expect(report.sourceReports.map(\.invalid) == [false, true, true, false])
|
||||
}
|
||||
|
||||
@Test("Batch reports can treat warnings as failures")
|
||||
func test_batchReportsCanTreatWarningsAsFailures() {
|
||||
let validator = PureYAML.Validation.Validator.blank.validating(
|
||||
PureYAML.Validation.Rule(
|
||||
description: "Mode is not legacy",
|
||||
severity: .warning,
|
||||
check: { context in context.subject != .string("legacy") },
|
||||
when: { context in context.path.components.last == .key("mode") },
|
||||
),
|
||||
)
|
||||
|
||||
let report = PureYAML.validationReports(
|
||||
[
|
||||
.init(name: "warnings.yaml", yaml: "mode: legacy"),
|
||||
],
|
||||
using: validator,
|
||||
failOnWarnings: true,
|
||||
)
|
||||
|
||||
#expect(report.failOnWarnings)
|
||||
#expect(report.fileCount == 1)
|
||||
#expect(report.validCount == 0)
|
||||
#expect(report.invalidCount == 1)
|
||||
#expect(!report.isValid)
|
||||
#expect(report.sourceReports == [
|
||||
.init(
|
||||
name: "warnings.yaml",
|
||||
report: .init([
|
||||
.init(
|
||||
kind: .validation,
|
||||
severity: .warning,
|
||||
file: "warnings.yaml",
|
||||
documentIndex: 0,
|
||||
path: .init([.key("mode")]),
|
||||
reason: "Failed to satisfy: Mode is not legacy",
|
||||
),
|
||||
]),
|
||||
invalid: true,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
@Test("Batch reports render exact Markdown file contents")
|
||||
func test_batchReportsRenderExactMarkdownFileContents() throws {
|
||||
let report = makeMarkdownValidationReport()
|
||||
let expected = expectedMarkdownValidationReport
|
||||
|
||||
#expect(report.markdownDescription(title: "YAML Production Validation") == expected)
|
||||
|
||||
#expect(try writeAndRead(report: report) == expected)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMarkdownValidationReport() -> PureYAML.Validation.BatchReport {
|
||||
PureYAML.validationReports(
|
||||
[
|
||||
.init(name: "valid.yaml", yaml: "title: Ready"),
|
||||
.init(name: "broken.yaml", yaml: "title: \"open"),
|
||||
.init(
|
||||
name: "duplicates.yaml",
|
||||
yaml: """
|
||||
title: First
|
||||
title: Second
|
||||
""",
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
private func writeAndRead(report: PureYAML.Validation.BatchReport) throws -> String {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"PureYAMLValidationReport-\(UUID().uuidString)",
|
||||
isDirectory: true,
|
||||
)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
|
||||
let file = directory.appendingPathComponent("validation-report.md")
|
||||
try report.markdownDescription(title: "YAML Production Validation")
|
||||
.write(to: file, atomically: true, encoding: .utf8)
|
||||
return try String(contentsOf: file, encoding: .utf8)
|
||||
}
|
||||
|
||||
private let expectedMarkdownValidationReport = """
|
||||
# YAML Production Validation
|
||||
|
||||
- Files: 3
|
||||
- Valid: 1
|
||||
- Invalid: 2
|
||||
- Diagnostics: 2
|
||||
- Warnings fail: no
|
||||
|
||||
## valid.yaml
|
||||
|
||||
Status: valid
|
||||
|
||||
No diagnostics.
|
||||
|
||||
## broken.yaml
|
||||
|
||||
Status: invalid
|
||||
|
||||
```text
|
||||
broken.yaml: error: parse: unterminated quoted string at line 1
|
||||
```
|
||||
|
||||
## duplicates.yaml
|
||||
|
||||
Status: invalid
|
||||
|
||||
```text
|
||||
duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'
|
||||
```
|
||||
|
||||
"""
|
||||
@ -50,6 +50,7 @@ Those tests are the executable contract for the short-form examples.
|
||||
| Emit YAML from stream documents | `PureYAML.dump(_:options:)` with `[PureYAML.Stream.Document]` | `String` |
|
||||
| Validate a value tree | `PureYAML.validate(_:using:strict:)` | `[PureYAML.Validation.Issue]` or a thrown issue collection |
|
||||
| Validate stream documents | `PureYAML.validate(_:using:strict:)` with `[PureYAML.Stream.Document]` | `[PureYAML.Stream.Issue]` or a thrown stream issue collection |
|
||||
| Produce non-throwing validation diagnostics | `PureYAML.validationReport(_:)` or `PureYAML.validationReports(_:)` | `PureYAML.Validation.Report` or `PureYAML.Validation.BatchReport` |
|
||||
| Decode a typed value from YAML | `PureYAML.decode(_:from:)` | `Decodable` value |
|
||||
| Encode a typed value to a value tree | `PureYAML.encode(_:)` | `PureYAML.Model.Value` |
|
||||
| Encode a typed value to YAML | `PureYAML.encodeToYAML(_:options:)` | `String` |
|
||||
@ -277,6 +278,26 @@ do {
|
||||
The issue descriptions include `document[0]`, `document[1]`, and so on, while
|
||||
the nested validation path still describes the location inside that document.
|
||||
|
||||
Use validation reports when a caller needs to process many faulty files without
|
||||
throwing away the rest of the batch. Parse failures become diagnostics, ordinary
|
||||
validation issues keep document indexes and paths, and warnings can optionally
|
||||
mark a source invalid:
|
||||
|
||||
```swift
|
||||
let report = PureYAML.validationReports(
|
||||
[
|
||||
.init(name: "valid.yaml", yaml: "title: Ready"),
|
||||
.init(name: "broken.yaml", yaml: "title: \"open"),
|
||||
],
|
||||
failOnWarnings: true
|
||||
)
|
||||
|
||||
let markdown = report.markdownDescription(title: "YAML Validation")
|
||||
```
|
||||
|
||||
PureYAML does not write files from the library target. Applications that need a
|
||||
validation document should write `markdown` through their own file IO layer.
|
||||
|
||||
## Cross-Platform Verification
|
||||
|
||||
Before relying on a migration, run the same gates used by the repository:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user