Add machine-readable validation reports
This commit is contained in:
parent
0e92e31d89
commit
13183a4353
@ -36,8 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- 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.
|
||||
and validation diagnostics, optional warning-as-failure status, and Markdown,
|
||||
YAML, and JSON rendering for application-owned report files.
|
||||
|
||||
### Changed
|
||||
|
||||
@ -56,6 +56,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Harden real-world parsing for empty documents, indented root fragments,
|
||||
JSON-style flow mapping pairs, and tab-indented files encountered in corpus
|
||||
validation.
|
||||
- Emit empty block collections as `[]` and `{}` so dumped YAML round-trips
|
||||
without turning empty arrays or mappings into null values.
|
||||
|
||||
## [0.1.0] - 2026-06-05
|
||||
|
||||
|
||||
@ -206,7 +206,7 @@ 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:
|
||||
and can render Markdown, YAML, or JSON that a consuming tool writes to disk:
|
||||
|
||||
```swift
|
||||
let report = PureYAML.validationReports([
|
||||
@ -215,6 +215,8 @@ let report = PureYAML.validationReports([
|
||||
])
|
||||
|
||||
let markdown = report.markdownDescription(title: "YAML Validation")
|
||||
let yaml = report.yamlDescription(title: "YAML Validation")
|
||||
let json = report.jsonDescription(title: "YAML Validation")
|
||||
```
|
||||
|
||||
Custom validation rules can be layered onto the default validator or attached to
|
||||
|
||||
@ -3,7 +3,12 @@ extension PureYAML.Emitting.Dumper {
|
||||
_ mapping: PureYAML.Model.Mapping,
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
mapping.pairs.flatMap { pair in
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
guard !mapping.pairs.isEmpty else {
|
||||
return [prefix + "{}"]
|
||||
}
|
||||
|
||||
return mapping.pairs.flatMap { pair in
|
||||
renderMappingPair(pair, indent: indent)
|
||||
}
|
||||
}
|
||||
@ -25,6 +30,10 @@ extension PureYAML.Emitting.Dumper {
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
if let emptyCollection = renderEmptyCollectionLiteral(pair.value) {
|
||||
return [prefix + escapeKey(pair.key) + ": " + emptyCollection]
|
||||
}
|
||||
|
||||
switch pair.value {
|
||||
case .mapping, .sequence:
|
||||
return [prefix + escapeKey(pair.key) + ":"] + render(pair.value, indent: indent + 2)
|
||||
@ -38,6 +47,11 @@ extension PureYAML.Emitting.Dumper {
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
if let emptyCollection = renderEmptyCollectionLiteral(pair.value) {
|
||||
return [prefix + "? " + pair.keyNode.flowDescription]
|
||||
+ [prefix + ": " + emptyCollection]
|
||||
}
|
||||
|
||||
return [prefix + "? " + pair.keyNode.flowDescription]
|
||||
+ [prefix + ":"]
|
||||
+ render(pair.value, indent: indent + 2)
|
||||
|
||||
@ -38,12 +38,20 @@ extension PureYAML.Emitting.Dumper {
|
||||
indent: Int,
|
||||
) -> [String] {
|
||||
let prefix = String(repeating: " ", count: indent)
|
||||
guard !values.isEmpty else {
|
||||
return [prefix + "[]"]
|
||||
}
|
||||
|
||||
return values.flatMap { value in
|
||||
if let emptyCollection = renderEmptyCollectionLiteral(value) {
|
||||
return [prefix + "- " + emptyCollection]
|
||||
}
|
||||
|
||||
switch value {
|
||||
case .mapping, .sequence:
|
||||
[prefix + "-"] + render(value, indent: indent + 2)
|
||||
return [prefix + "-"] + render(value, indent: indent + 2)
|
||||
default:
|
||||
renderSequenceScalarItem(value, indent: indent)
|
||||
return renderSequenceScalarItem(value, indent: indent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -75,6 +83,17 @@ extension PureYAML.Emitting.Dumper {
|
||||
return "[\(items)]"
|
||||
}
|
||||
|
||||
func renderEmptyCollectionLiteral(_ value: PureYAML.Model.Value) -> String? {
|
||||
switch value {
|
||||
case let .mapping(mapping) where mapping.pairs.isEmpty:
|
||||
"{}"
|
||||
case let .sequence(values) where values.isEmpty:
|
||||
"[]"
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
func renderFlowScalar(_ value: PureYAML.Model.Value) -> String {
|
||||
switch value {
|
||||
case .null:
|
||||
|
||||
@ -41,6 +41,10 @@ public extension PureYAML.Validation {
|
||||
public var description: String {
|
||||
diagnostics.map(\.description).joined(separator: "\n")
|
||||
}
|
||||
|
||||
public func modelValue() -> PureYAML.Model.Value {
|
||||
.sequence(diagnostics.map(\.modelValue))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validation result for one named source in a batch.
|
||||
@ -54,6 +58,15 @@ public extension PureYAML.Validation {
|
||||
self.report = report
|
||||
self.invalid = invalid
|
||||
}
|
||||
|
||||
public func modelValue() -> PureYAML.Model.Value {
|
||||
.mapping(.init([
|
||||
.init(key: "name", value: .string(name)),
|
||||
.init(key: "status", value: .string(invalid ? "invalid" : "valid")),
|
||||
.init(key: "invalid", value: .bool(invalid)),
|
||||
.init(key: "diagnostics", value: report.modelValue()),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-throwing aggregate report for validating many YAML inputs.
|
||||
@ -132,6 +145,149 @@ public extension PureYAML.Validation {
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
public func modelValue(
|
||||
title: String = "PureYAML Validation Report",
|
||||
) -> PureYAML.Model.Value {
|
||||
.mapping(.init([
|
||||
.init(key: "title", value: .string(title)),
|
||||
.init(key: "summary", value: summaryModelValue),
|
||||
.init(key: "sources", value: .sequence(sourceReports.map { $0.modelValue() })),
|
||||
]))
|
||||
}
|
||||
|
||||
public func yamlDescription(
|
||||
title: String = "PureYAML Validation Report",
|
||||
) -> String {
|
||||
PureYAML.dump(modelValue(title: title))
|
||||
}
|
||||
|
||||
public func jsonDescription(
|
||||
title: String = "PureYAML Validation Report",
|
||||
) -> String {
|
||||
modelValue(title: title).jsonDescription + "\n"
|
||||
}
|
||||
|
||||
private var summaryModelValue: PureYAML.Model.Value {
|
||||
.mapping(.init([
|
||||
.init(key: "files", value: .int(fileCount)),
|
||||
.init(key: "valid", value: .int(validCount)),
|
||||
.init(key: "invalid", value: .int(invalidCount)),
|
||||
.init(key: "diagnostics", value: .int(diagnosticCount)),
|
||||
.init(key: "warningsFail", value: .bool(failOnWarnings)),
|
||||
]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension PureYAML.Validation.Diagnostic {
|
||||
var modelValue: PureYAML.Model.Value {
|
||||
.mapping(.init([
|
||||
.init(key: "kind", value: .string(kind.description)),
|
||||
.init(key: "severity", value: .string(severity.description)),
|
||||
.init(key: "file", value: file.map(PureYAML.Model.Value.string) ?? .null),
|
||||
.init(key: "documentIndex", value: documentIndex.map(PureYAML.Model.Value.int) ?? .null),
|
||||
.init(key: "path", value: path.map { .string($0.isRoot ? "root" : $0.description) } ?? .null),
|
||||
.init(key: "reason", value: .string(reason)),
|
||||
.init(key: "description", value: .string(description)),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
private extension PureYAML.Model.Value {
|
||||
var jsonDescription: String {
|
||||
jsonDescription(indentation: 0)
|
||||
}
|
||||
|
||||
func jsonDescription(indentation: Int) -> String {
|
||||
switch self {
|
||||
case .null:
|
||||
"null"
|
||||
case let .bool(value):
|
||||
value ? "true" : "false"
|
||||
case let .int(value):
|
||||
String(value)
|
||||
case let .double(value):
|
||||
value.isFinite ? String(value) : "null"
|
||||
case let .string(value):
|
||||
value.jsonEscaped
|
||||
case let .sequence(values):
|
||||
values.jsonDescription(indentation: indentation)
|
||||
case let .mapping(mapping):
|
||||
mapping.jsonDescription(indentation: indentation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension [PureYAML.Model.Value] {
|
||||
func jsonDescription(indentation: Int) -> String {
|
||||
guard !isEmpty else {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
let nextIndentation = indentation + 2
|
||||
let lines = map { value in
|
||||
"\(String.spaces(nextIndentation))\(value.jsonDescription(indentation: nextIndentation))"
|
||||
}
|
||||
return "[\n\(lines.joined(separator: ",\n"))\n\(String.spaces(indentation))]"
|
||||
}
|
||||
}
|
||||
|
||||
private extension PureYAML.Model.Mapping {
|
||||
func jsonDescription(indentation: Int) -> String {
|
||||
guard !pairs.isEmpty else {
|
||||
return "{}"
|
||||
}
|
||||
|
||||
let nextIndentation = indentation + 2
|
||||
let lines = pairs.map { pair in
|
||||
let value = pair.value.jsonDescription(indentation: nextIndentation)
|
||||
return "\(String.spaces(nextIndentation))\(pair.key.jsonEscaped): \(value)"
|
||||
}
|
||||
return "{\n\(lines.joined(separator: ",\n"))\n\(String.spaces(indentation))}"
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
static func spaces(_ count: Int) -> String {
|
||||
String(repeating: " ", count: count)
|
||||
}
|
||||
|
||||
var jsonEscaped: String {
|
||||
var output = "\""
|
||||
for scalar in unicodeScalars {
|
||||
switch scalar.value {
|
||||
case 0x08:
|
||||
output += "\\b"
|
||||
case 0x09:
|
||||
output += "\\t"
|
||||
case 0x0A:
|
||||
output += "\\n"
|
||||
case 0x0C:
|
||||
output += "\\f"
|
||||
case 0x0D:
|
||||
output += "\\r"
|
||||
case 0x22:
|
||||
output += "\\\""
|
||||
case 0x5C:
|
||||
output += "\\\\"
|
||||
case 0x00 ... 0x1F:
|
||||
output += scalar.value.jsonControlEscape
|
||||
default:
|
||||
output.unicodeScalars.append(scalar)
|
||||
}
|
||||
}
|
||||
output += "\""
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
private extension UInt32 {
|
||||
var jsonControlEscape: String {
|
||||
let digits = Array("0123456789abcdef")
|
||||
return "\\u" + stride(from: 12, through: 0, by: -4).map { shift in
|
||||
String(digits[Int((self >> UInt32(shift)) & 0xF)])
|
||||
}.joined()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -72,7 +72,7 @@ struct CodableCompatibilityTests {
|
||||
- "two"
|
||||
-
|
||||
heading: "Outro"
|
||||
items:
|
||||
items: []
|
||||
|
||||
""")
|
||||
#expect(yaml.contains("sections:"))
|
||||
|
||||
36
Tests/DumperEmptyCollectionTests.swift
Normal file
36
Tests/DumperEmptyCollectionTests.swift
Normal file
@ -0,0 +1,36 @@
|
||||
@testable import PureYAML
|
||||
import Testing
|
||||
|
||||
@Suite("Dumper Empty Collections")
|
||||
struct DumperEmptyCollectionTests {
|
||||
@Test("Dumps empty collections")
|
||||
func test_emptyCollections() throws {
|
||||
#expect(PureYAML.dump(.mapping(.init())) == "{}\n")
|
||||
#expect(PureYAML.dump(.sequence([])) == "[]\n")
|
||||
#expect(try PureYAML.parse(PureYAML.dump(.mapping(.init()))) == .mapping(.init()))
|
||||
#expect(try PureYAML.parse(PureYAML.dump(.sequence([]))) == .sequence([]))
|
||||
|
||||
let nested = PureYAML.Model.Value.mapping(.init([
|
||||
.init(key: "mapping", value: .mapping(.init())),
|
||||
.init(key: "sequence", value: .sequence([])),
|
||||
.init(key: "items", value: .sequence([
|
||||
.sequence([]),
|
||||
.mapping(.init()),
|
||||
])),
|
||||
]))
|
||||
|
||||
#expect(PureYAML.dump(nested) == """
|
||||
mapping: {}
|
||||
sequence: []
|
||||
items:
|
||||
- []
|
||||
- {}
|
||||
|
||||
""")
|
||||
#expect(try PureYAML.parse(PureYAML.dump(nested)) == nested)
|
||||
|
||||
let options = PureYAML.Emitting.Options(collectionStyle: .flow)
|
||||
#expect(PureYAML.dump(.mapping(.init()), options: options) == "{}\n")
|
||||
#expect(PureYAML.dump(.sequence([]), options: options) == "[]\n")
|
||||
}
|
||||
}
|
||||
@ -91,16 +91,6 @@ struct DumperTests {
|
||||
#expect(try PureYAML.parse(PureYAML.dump(original)) == original)
|
||||
}
|
||||
|
||||
@Test("Dumps empty collections")
|
||||
func test_emptyCollections() {
|
||||
#expect(PureYAML.dump(.mapping(.init())) == "\n")
|
||||
#expect(PureYAML.dump(.sequence([])) == "\n")
|
||||
|
||||
let options = PureYAML.Emitting.Options(collectionStyle: .flow)
|
||||
#expect(PureYAML.dump(.mapping(.init()), options: options) == "{}\n")
|
||||
#expect(PureYAML.dump(.sequence([]), options: options) == "[]\n")
|
||||
}
|
||||
|
||||
@Test("Dumps plain strings only when selected and safe")
|
||||
func test_plainStringScalarPolicy() throws {
|
||||
let value = PureYAML.Model.Value.mapping(.init([
|
||||
|
||||
@ -73,7 +73,7 @@ struct UnkeyedCodingTests {
|
||||
- 2
|
||||
|
||||
""")
|
||||
#expect(try PureYAML.encodeToYAML([Int]()) == "\n")
|
||||
#expect(try PureYAML.encodeToYAML([Int]()) == "[]\n")
|
||||
}
|
||||
|
||||
@Test("Unkeyed super encoders reserve distinct indexes before values are written")
|
||||
|
||||
@ -99,7 +99,7 @@ let sequenceEncodeFixtures: [SequenceEncodeFixture] = [
|
||||
-
|
||||
- 1
|
||||
- 2
|
||||
-
|
||||
- []
|
||||
|
||||
""",
|
||||
forbiddenYAMLFragments: ["null"],
|
||||
@ -140,7 +140,7 @@ let sequenceEncodeFixtures: [SequenceEncodeFixture] = [
|
||||
featured:
|
||||
- "Typed YAML"
|
||||
- null
|
||||
empty:
|
||||
empty: []
|
||||
|
||||
""",
|
||||
forbiddenYAMLFragments: ["missing:", "- 0"],
|
||||
|
||||
@ -177,6 +177,27 @@ struct ValidationReportTests {
|
||||
|
||||
#expect(try writeAndRead(report: report) == expected)
|
||||
}
|
||||
|
||||
@Test("Batch reports render machine-readable YAML and JSON files")
|
||||
func test_batchReportsRenderMachineReadableYAMLAndJSONFiles() throws {
|
||||
let report = makeMarkdownValidationReport()
|
||||
let title = "YAML Production Validation"
|
||||
|
||||
#expect(report.modelValue(title: title) == expectedReportModelValue)
|
||||
#expect(try PureYAML.parse(report.yamlDescription(title: title)) == expectedReportModelValue)
|
||||
#expect(try jsonSummary(report.jsonDescription(title: title)) == [
|
||||
"diagnostics": 2,
|
||||
"files": 3,
|
||||
"invalid": 2,
|
||||
"valid": 1,
|
||||
])
|
||||
|
||||
let outputs = try writeAndReadMachineReports(report: report, title: title)
|
||||
|
||||
#expect(try PureYAML.parse(outputs.yaml) == expectedReportModelValue)
|
||||
#expect(outputs.json == report.jsonDescription(title: title))
|
||||
#expect(outputs.json.contains(#""reason": "Duplicate mapping key 'title'""#))
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMarkdownValidationReport() -> PureYAML.Validation.BatchReport {
|
||||
@ -211,6 +232,46 @@ private func writeAndRead(report: PureYAML.Validation.BatchReport) throws -> Str
|
||||
return try String(contentsOf: file, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func writeAndReadMachineReports(
|
||||
report: PureYAML.Validation.BatchReport,
|
||||
title: String,
|
||||
) throws -> (yaml: String, json: String) {
|
||||
let directory = try temporaryValidationReportDirectory()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
|
||||
let yamlFile = directory.appendingPathComponent("validation-report.yaml")
|
||||
let jsonFile = directory.appendingPathComponent("validation-report.json")
|
||||
try report.yamlDescription(title: title).write(to: yamlFile, atomically: true, encoding: .utf8)
|
||||
try report.jsonDescription(title: title).write(to: jsonFile, atomically: true, encoding: .utf8)
|
||||
return try (
|
||||
String(contentsOf: yamlFile, encoding: .utf8),
|
||||
String(contentsOf: jsonFile, encoding: .utf8)
|
||||
)
|
||||
}
|
||||
|
||||
private func temporaryValidationReportDirectory() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"PureYAMLValidationReport-\(UUID().uuidString)",
|
||||
isDirectory: true,
|
||||
)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
private func jsonSummary(_ json: String) throws -> [String: Int] {
|
||||
let data = Data(json.utf8)
|
||||
let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let summary = try #require(object["summary"] as? [String: Any])
|
||||
return try [
|
||||
"diagnostics": #require(summary["diagnostics"] as? Int),
|
||||
"files": #require(summary["files"] as? Int),
|
||||
"invalid": #require(summary["invalid"] as? Int),
|
||||
"valid": #require(summary["valid"] as? Int),
|
||||
]
|
||||
}
|
||||
|
||||
private let expectedMarkdownValidationReport = """
|
||||
# YAML Production Validation
|
||||
|
||||
@ -243,3 +304,62 @@ duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
private let expectedReportModelValue = PureYAML.Model.Value.mapping(.init([
|
||||
.init(key: "title", value: .string("YAML Production Validation")),
|
||||
.init(key: "summary", value: .mapping(.init([
|
||||
.init(key: "files", value: .int(3)),
|
||||
.init(key: "valid", value: .int(1)),
|
||||
.init(key: "invalid", value: .int(2)),
|
||||
.init(key: "diagnostics", value: .int(2)),
|
||||
.init(key: "warningsFail", value: .bool(false)),
|
||||
]))),
|
||||
.init(key: "sources", value: .sequence([
|
||||
.mapping(.init([
|
||||
.init(key: "name", value: .string("valid.yaml")),
|
||||
.init(key: "status", value: .string("valid")),
|
||||
.init(key: "invalid", value: .bool(false)),
|
||||
.init(key: "diagnostics", value: .sequence([])),
|
||||
])),
|
||||
.mapping(.init([
|
||||
.init(key: "name", value: .string("broken.yaml")),
|
||||
.init(key: "status", value: .string("invalid")),
|
||||
.init(key: "invalid", value: .bool(true)),
|
||||
.init(key: "diagnostics", value: .sequence([
|
||||
.mapping(.init([
|
||||
.init(key: "kind", value: .string("parse")),
|
||||
.init(key: "severity", value: .string("error")),
|
||||
.init(key: "file", value: .string("broken.yaml")),
|
||||
.init(key: "documentIndex", value: .null),
|
||||
.init(key: "path", value: .null),
|
||||
.init(key: "reason", value: .string("unterminated quoted string at line 1")),
|
||||
.init(
|
||||
key: "description",
|
||||
value: .string("broken.yaml: error: parse: unterminated quoted string at line 1"),
|
||||
),
|
||||
])),
|
||||
])),
|
||||
])),
|
||||
.mapping(.init([
|
||||
.init(key: "name", value: .string("duplicates.yaml")),
|
||||
.init(key: "status", value: .string("invalid")),
|
||||
.init(key: "invalid", value: .bool(true)),
|
||||
.init(key: "diagnostics", value: .sequence([
|
||||
.mapping(.init([
|
||||
.init(key: "kind", value: .string("validation")),
|
||||
.init(key: "severity", value: .string("error")),
|
||||
.init(key: "file", value: .string("duplicates.yaml")),
|
||||
.init(key: "documentIndex", value: .int(0)),
|
||||
.init(key: "path", value: .string("$.title")),
|
||||
.init(key: "reason", value: .string("Duplicate mapping key 'title'")),
|
||||
.init(
|
||||
key: "description",
|
||||
value: .string(
|
||||
"duplicates.yaml: document[0]: $.title: error: validation: Duplicate mapping key 'title'",
|
||||
),
|
||||
),
|
||||
])),
|
||||
])),
|
||||
])),
|
||||
])),
|
||||
]))
|
||||
|
||||
@ -293,10 +293,13 @@ let report = PureYAML.validationReports(
|
||||
)
|
||||
|
||||
let markdown = report.markdownDescription(title: "YAML Validation")
|
||||
let yaml = report.yamlDescription(title: "YAML Validation")
|
||||
let json = report.jsonDescription(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.
|
||||
validation document should write `markdown`, `yaml`, or `json` through their
|
||||
own file IO layer.
|
||||
|
||||
## Cross-Platform Verification
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user