lens_core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +13 -0
- data/LICENSE +25 -0
- data/README.adoc +62 -0
- data/Rakefile +12 -0
- data/docs/diagnostics-v1.adoc +237 -0
- data/lens_core.gemspec +32 -0
- data/lib/lens_core/citation.rb +50 -0
- data/lib/lens_core/diagnostic.rb +66 -0
- data/lib/lens_core/errors.rb +48 -0
- data/lib/lens_core/location.rb +81 -0
- data/lib/lens_core/node.rb +59 -0
- data/lib/lens_core/parsers/json.rb +25 -0
- data/lib/lens_core/parsers/text.rb +41 -0
- data/lib/lens_core/parsers/yaml.rb +68 -0
- data/lib/lens_core/producer.rb +28 -0
- data/lib/lens_core/profile.rb +29 -0
- data/lib/lens_core/renderers/human.rb +85 -0
- data/lib/lens_core/renderers/json.rb +18 -0
- data/lib/lens_core/renderers/yaml.rb +16 -0
- data/lib/lens_core/report.rb +141 -0
- data/lib/lens_core/schema.rb +22 -0
- data/lib/lens_core/validation.rb +164 -0
- data/lib/lens_core/version.rb +10 -0
- data/lib/lens_core.rb +25 -0
- data/schemas/lens-diagnostics-v1.json +119 -0
- metadata +71 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LensCore
|
|
4
|
+
# Base of the immutable value objects. A node has standard members (named by
|
|
5
|
+
# MEMBERS, in schema order) plus extensions: additional JSON-safe members a
|
|
6
|
+
# compatible v1 producer may add. Extensions are kept and re-emitted after
|
|
7
|
+
# the standard members, sorted lexically.
|
|
8
|
+
class Node
|
|
9
|
+
attr_reader :extensions
|
|
10
|
+
|
|
11
|
+
def self.members
|
|
12
|
+
self::MEMBERS
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Splits a wire object into [standard members, extensions]. A standard
|
|
16
|
+
# member is never null: absent optional members are omitted.
|
|
17
|
+
def self.split(hash, name = nil)
|
|
18
|
+
Validation.object(hash, name)
|
|
19
|
+
known, extra = hash.partition { |key, _| members.include?(key) }
|
|
20
|
+
.map(&:to_h)
|
|
21
|
+
reject_nulls(known)
|
|
22
|
+
[known, extra]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.reject_nulls(known)
|
|
26
|
+
known.each do |key, value|
|
|
27
|
+
next unless value.nil?
|
|
28
|
+
|
|
29
|
+
Validation.fail_at("must not be null (omit the member instead)", key)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
private_class_method :reject_nulls
|
|
33
|
+
|
|
34
|
+
def initialize(extensions: {})
|
|
35
|
+
@extensions = Validation.extensions(extensions, self.class.members)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Canonical wire form: standard members in schema order (absent optional
|
|
39
|
+
# members omitted), then extensions.
|
|
40
|
+
def to_h
|
|
41
|
+
standard_members.compact.merge(extensions)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def ==(other)
|
|
45
|
+
other.instance_of?(self.class) && other.to_h == to_h
|
|
46
|
+
end
|
|
47
|
+
alias eql? ==
|
|
48
|
+
|
|
49
|
+
def hash
|
|
50
|
+
[self.class, to_h].hash
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def standard_members
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LensCore
|
|
6
|
+
module Parsers
|
|
7
|
+
# JSON text -> Report.
|
|
8
|
+
module Json
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @param input [String] a JSON document
|
|
12
|
+
# @return [LensCore::Report]
|
|
13
|
+
# @raise [LensCore::Error] see lib/lens_core/errors.rb
|
|
14
|
+
def parse(input)
|
|
15
|
+
Report.from_h(decode(Text.decode(input)))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def decode(text)
|
|
19
|
+
::JSON.parse(text, allow_nan: false)
|
|
20
|
+
rescue ::JSON::ParserError => e
|
|
21
|
+
raise ParseError, "malformed JSON: #{e.message.lines.first.to_s.strip}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LensCore
|
|
4
|
+
module Parsers
|
|
5
|
+
# Input decoding shared by the JSON and YAML parsers.
|
|
6
|
+
module Text
|
|
7
|
+
BOM = ""
|
|
8
|
+
READABLE = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::BINARY].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Returns the input as valid UTF-8 text, or raises. Strings tagged
|
|
13
|
+
# UTF-8, US-ASCII or binary (the usual result of reading a file in
|
|
14
|
+
# "rb" mode) are read as UTF-8; any other encoding is rejected rather
|
|
15
|
+
# than transcoded, so every accepted byte stream has one meaning.
|
|
16
|
+
def decode(input)
|
|
17
|
+
unless input.is_a?(String)
|
|
18
|
+
raise ParseError, "expected a String, got #{input.class}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
text = utf8(input)
|
|
22
|
+
if text.start_with?(BOM)
|
|
23
|
+
raise ParseError, "a byte order mark is not allowed"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
text
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def utf8(input)
|
|
30
|
+
unless READABLE.include?(input.encoding)
|
|
31
|
+
raise InvalidEncodingError, "input is #{input.encoding}, not UTF-8"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
text = input.dup.force_encoding(Encoding::UTF_8)
|
|
35
|
+
return text if text.valid_encoding?
|
|
36
|
+
|
|
37
|
+
raise InvalidEncodingError, "input is not valid UTF-8"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module LensCore
|
|
6
|
+
module Parsers
|
|
7
|
+
# YAML text -> Report. Only plain data is loaded: no object tags, symbols
|
|
8
|
+
# or aliases. A Time is permitted solely so an unquoted RFC 3339
|
|
9
|
+
# `timestamp` works; anywhere else it fails as "must be a string".
|
|
10
|
+
# The document is first read as a Psych AST, so explicit tags other than
|
|
11
|
+
# the core scalar and collection ones are refused before any object is
|
|
12
|
+
# built, and the unquoted `timestamp` is checked in its written form.
|
|
13
|
+
module Yaml
|
|
14
|
+
CORE_TAG =
|
|
15
|
+
/\A(?:!!|tag:yaml\.org,2002:)(?:str|int|float|bool|null|map|seq)\z/
|
|
16
|
+
DATE_LIKE = /\A\d{4}-\d/
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @param input [String] a YAML document
|
|
21
|
+
# @return [LensCore::Report]
|
|
22
|
+
# @raise [LensCore::Error] see lib/lens_core/errors.rb
|
|
23
|
+
def parse(input)
|
|
24
|
+
Report.from_h(decode(Text.decode(input)))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def decode(text)
|
|
28
|
+
preflight(::YAML.parse(text))
|
|
29
|
+
::YAML.safe_load(text, permitted_classes: [Time],
|
|
30
|
+
permitted_symbols: [], aliases: false)
|
|
31
|
+
rescue ::Psych::DisallowedClass, ::Psych::BadAlias => e
|
|
32
|
+
raise UnsafeYamlError, "unsafe YAML: #{e.message}"
|
|
33
|
+
rescue ::Psych::SyntaxError => e
|
|
34
|
+
raise ParseError, "malformed YAML: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def preflight(document)
|
|
38
|
+
return unless document
|
|
39
|
+
|
|
40
|
+
document.root.each do |node|
|
|
41
|
+
tag = node.tag
|
|
42
|
+
next if tag.nil? || CORE_TAG.match?(tag)
|
|
43
|
+
|
|
44
|
+
raise UnsafeYamlError, "unsafe YAML: tag #{tag} is not allowed"
|
|
45
|
+
end
|
|
46
|
+
check_timestamp(document.root)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Psych turns an unquoted date-shaped scalar into a Time and forgets how
|
|
50
|
+
# it was written, so the written form is validated here.
|
|
51
|
+
def check_timestamp(root)
|
|
52
|
+
return unless root.is_a?(::Psych::Nodes::Mapping)
|
|
53
|
+
|
|
54
|
+
root.children.each_slice(2) do |key, value|
|
|
55
|
+
next unless key.respond_to?(:value) && key.value == "timestamp"
|
|
56
|
+
next unless written_date?(value)
|
|
57
|
+
|
|
58
|
+
Validation.timestamp(value.value, "timestamp")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def written_date?(node)
|
|
63
|
+
node.is_a?(::Psych::Nodes::Scalar) && node.plain &&
|
|
64
|
+
DATE_LIKE.match?(node.value)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LensCore
|
|
4
|
+
# The tool that produced a report.
|
|
5
|
+
class Producer < Node
|
|
6
|
+
MEMBERS = %w[name version].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :name, :version
|
|
9
|
+
|
|
10
|
+
def self.from_h(hash)
|
|
11
|
+
known, extra = split(hash)
|
|
12
|
+
new(name: known["name"], version: known["version"], extensions: extra)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(name:, version:, extensions: {})
|
|
16
|
+
@name = Validation.string(name, "name")
|
|
17
|
+
@version = Validation.string(version, "version")
|
|
18
|
+
super(extensions: extensions)
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def standard_members
|
|
25
|
+
{ "name" => name, "version" => version }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LensCore
|
|
4
|
+
# The profile (rule set) a document was checked against.
|
|
5
|
+
class Profile < Node
|
|
6
|
+
MEMBERS = %w[id citations].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :id, :citations
|
|
9
|
+
|
|
10
|
+
def self.from_h(hash)
|
|
11
|
+
known, extra = split(hash)
|
|
12
|
+
new(id: known["id"], citations: Citation.list_from_h(known["citations"]),
|
|
13
|
+
extensions: extra)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(id:, citations: [], extensions: {})
|
|
17
|
+
@id = Validation.string(id, "id")
|
|
18
|
+
@citations = Citation.check_list(citations)
|
|
19
|
+
super(extensions: extensions)
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def standard_members
|
|
26
|
+
{ "id" => id, "citations" => citations.map(&:to_h) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LensCore
|
|
6
|
+
module Renderers
|
|
7
|
+
# Report -> plain text for people. No colour, no terminal control codes,
|
|
8
|
+
# no wrapping: the same report always yields the same text.
|
|
9
|
+
#
|
|
10
|
+
# svg-lens 0.2.4, profile example-profile
|
|
11
|
+
# INVALID: 1 error, 1 warning, 1 info
|
|
12
|
+
#
|
|
13
|
+
# error REQ-001 (unsupported_element)
|
|
14
|
+
# at line 3, column 5, /svg/foo
|
|
15
|
+
# <message>
|
|
16
|
+
# fix: <remediation>
|
|
17
|
+
# see: <label> <uri>
|
|
18
|
+
# context: {"key":"value"}
|
|
19
|
+
module Human
|
|
20
|
+
# How each coordinate reads, in display order.
|
|
21
|
+
PLACES = {
|
|
22
|
+
"xml" => [["line", "line "], ["column", "column "], ["path", ""]],
|
|
23
|
+
"binary" => [
|
|
24
|
+
["byte_offset", "byte offset "],
|
|
25
|
+
["byte_length", "length "],
|
|
26
|
+
["record_index", "record "],
|
|
27
|
+
["record_type", ""],
|
|
28
|
+
],
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def render(report)
|
|
34
|
+
lines = header(report)
|
|
35
|
+
report.diagnostics.each { |d| lines.push("", *entry(d)) }
|
|
36
|
+
"#{lines.join("\n")}\n"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def header(report)
|
|
40
|
+
["#{report.producer.name} #{report.producer.version}, " \
|
|
41
|
+
"profile #{report.profile.id}",
|
|
42
|
+
*("Checked at #{report.to_h['timestamp']}" if report.timestamp),
|
|
43
|
+
verdict(report)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def verdict(report)
|
|
47
|
+
counts = report.summary.map do |key, count|
|
|
48
|
+
noun = key.delete_suffix("s")
|
|
49
|
+
"#{count} #{noun}#{'s' unless count == 1}"
|
|
50
|
+
end
|
|
51
|
+
"#{report.valid ? 'VALID' : 'INVALID'}: #{counts.join(', ')}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def entry(diagnostic)
|
|
55
|
+
[headline(diagnostic),
|
|
56
|
+
*(" at #{place(diagnostic.location)}" if diagnostic.location),
|
|
57
|
+
*diagnostic.message.each_line(chomp: true).map { |l| " #{l}" },
|
|
58
|
+
*(" fix: #{diagnostic.remediation}" if diagnostic.remediation),
|
|
59
|
+
*diagnostic.citations.map { |c| citation_line(c) },
|
|
60
|
+
*context_line(diagnostic.context)]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def citation_line(citation)
|
|
64
|
+
" see: #{[citation.label, citation.uri].compact.join(' ')}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def headline(diagnostic)
|
|
68
|
+
"#{diagnostic.severity} #{diagnostic.requirement_id} " \
|
|
69
|
+
"(#{diagnostic.kind})"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def context_line(context)
|
|
73
|
+
context.empty? ? [] : [" context: #{::JSON.generate(context)}"]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def place(location)
|
|
77
|
+
parts = PLACES.fetch(location.type).filter_map do |key, prefix|
|
|
78
|
+
value = location.coordinates[key]
|
|
79
|
+
"#{prefix}#{value}" unless value.nil?
|
|
80
|
+
end
|
|
81
|
+
parts.empty? ? "(#{location.type}, no coordinates)" : parts.join(", ")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LensCore
|
|
6
|
+
module Renderers
|
|
7
|
+
# Report -> canonical JSON: two-space indent, schema key order, one
|
|
8
|
+
# trailing newline. Parsing canonical JSON and rendering it again
|
|
9
|
+
# returns the same bytes.
|
|
10
|
+
module Json
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def render(report)
|
|
14
|
+
"#{::JSON.pretty_generate(report.to_h)}\n"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module LensCore
|
|
6
|
+
module Renderers
|
|
7
|
+
# Report -> YAML with the same content and key order as the JSON form.
|
|
8
|
+
module Yaml
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def render(report)
|
|
12
|
+
::YAML.dump(report.to_h, line_width: -1)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LensCore
|
|
4
|
+
# A complete diagnostics report (schema version 1).
|
|
5
|
+
#
|
|
6
|
+
# `valid` is stated by the producer, not derived: whether warnings fail a
|
|
7
|
+
# document is the producer's policy. It cannot be true while the report
|
|
8
|
+
# holds an error. `summary` is always derived from the diagnostics.
|
|
9
|
+
class Report < Node
|
|
10
|
+
MEMBERS = %w[schema_version producer profile timestamp valid summary
|
|
11
|
+
diagnostics].freeze
|
|
12
|
+
SUMMARY_KEYS = {
|
|
13
|
+
"errors" => "error", "warnings" => "warning", "infos" => "info"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :producer, :profile, :valid, :diagnostics, :timestamp
|
|
17
|
+
|
|
18
|
+
# Builds a Report from a decoded JSON/YAML object. Raises
|
|
19
|
+
# UnsupportedVersionError, SchemaError or InvalidEncodingError.
|
|
20
|
+
def self.from_h(hash)
|
|
21
|
+
unless hash.is_a?(Hash)
|
|
22
|
+
raise SchemaError, "the document must be an object"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
check_version(hash["schema_version"])
|
|
26
|
+
known, extra = split(hash)
|
|
27
|
+
build(known, extra).tap { |r| r.check_summary(known["summary"]) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.check_version(version)
|
|
31
|
+
Validation.integer(version, "schema_version", min: 0)
|
|
32
|
+
return if version == SCHEMA_VERSION
|
|
33
|
+
|
|
34
|
+
raise UnsupportedVersionError,
|
|
35
|
+
"schema_version #{version} is not supported " \
|
|
36
|
+
"(this is LensCore #{VERSION}, schema version #{SCHEMA_VERSION})"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.build(known, extra)
|
|
40
|
+
new(
|
|
41
|
+
producer: within("producer") { Producer.from_h(known["producer"]) },
|
|
42
|
+
profile: within("profile") { Profile.from_h(known["profile"]) },
|
|
43
|
+
diagnostics: diagnostics_from(known["diagnostics"]),
|
|
44
|
+
valid: known["valid"], timestamp: known["timestamp"], extensions: extra
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.within(name)
|
|
49
|
+
yield
|
|
50
|
+
rescue SchemaError => e
|
|
51
|
+
raise e.within(name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.diagnostics_from(list)
|
|
55
|
+
Validation.array(list, "diagnostics").each_with_index.map do |item, i|
|
|
56
|
+
within("diagnostics") { within(i) { Diagnostic.from_h(item) } }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
private_class_method :check_version, :build, :within, :diagnostics_from
|
|
60
|
+
|
|
61
|
+
# @param timestamp [Time, String, nil] supplied by the caller; never
|
|
62
|
+
# defaulted to the current time.
|
|
63
|
+
def initialize(producer:, profile:, valid:, diagnostics: [],
|
|
64
|
+
timestamp: nil, extensions: {})
|
|
65
|
+
@producer = check_type(producer, Producer, "producer")
|
|
66
|
+
@profile = check_type(profile, Profile, "profile")
|
|
67
|
+
@diagnostics = check_diagnostics(diagnostics)
|
|
68
|
+
@valid = check_valid(valid)
|
|
69
|
+
@timestamp = Validation.timestamp(timestamp, "timestamp")
|
|
70
|
+
super(extensions: extensions)
|
|
71
|
+
freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def schema_version
|
|
75
|
+
SCHEMA_VERSION
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Counts derived from the diagnostics.
|
|
79
|
+
def summary
|
|
80
|
+
SUMMARY_KEYS.transform_values { |severity| count(severity) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The wire summary is redundant; reject one that disagrees.
|
|
84
|
+
# @api private
|
|
85
|
+
def check_summary(wire)
|
|
86
|
+
Validation.object(wire, "summary")
|
|
87
|
+
summary.each do |key, expected|
|
|
88
|
+
actual = Validation.integer(wire[key], ["summary", key], min: 0)
|
|
89
|
+
next if actual == expected
|
|
90
|
+
|
|
91
|
+
Validation.fail_at(
|
|
92
|
+
"is #{actual} but the diagnostics contain #{expected}",
|
|
93
|
+
["summary", key],
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def count(severity)
|
|
101
|
+
diagnostics.count { |d| d.severity == severity }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def check_type(value, type, name)
|
|
105
|
+
return value if value.is_a?(type)
|
|
106
|
+
|
|
107
|
+
Validation.fail_at("must be a #{type}", name)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def check_diagnostics(list)
|
|
111
|
+
Validation.array(list, "diagnostics").each_with_index.map do |item, i|
|
|
112
|
+
check_type(item, Diagnostic, ["diagnostics", i])
|
|
113
|
+
end.freeze
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def check_valid(value)
|
|
117
|
+
Validation.boolean(value, "valid")
|
|
118
|
+
return value unless value && diagnostics.any?(&:error?)
|
|
119
|
+
|
|
120
|
+
Validation.fail_at("cannot be true when the report has errors", "valid")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def standard_members
|
|
124
|
+
{
|
|
125
|
+
"schema_version" => schema_version, "producer" => producer.to_h,
|
|
126
|
+
"profile" => profile.to_h, "timestamp" => format_timestamp,
|
|
127
|
+
"valid" => valid, "summary" => summary,
|
|
128
|
+
"diagnostics" => diagnostics.map(&:to_h)
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# UTC, with sub-second digits only when there are some.
|
|
133
|
+
def format_timestamp
|
|
134
|
+
return nil unless timestamp
|
|
135
|
+
|
|
136
|
+
digits = format("%09d", timestamp.nsec).sub(/0+\z/, "")
|
|
137
|
+
fraction = digits.empty? ? "" : ".#{digits}"
|
|
138
|
+
"#{timestamp.strftime('%Y-%m-%dT%H:%M:%S')}#{fraction}Z"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LensCore
|
|
6
|
+
# The normative JSON Schema shipped with the gem.
|
|
7
|
+
module Schema
|
|
8
|
+
PATH = File.expand_path("../../schemas/lens-diagnostics-v1.json", __dir__)
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Schema version, as written in a report's `schema_version` member.
|
|
13
|
+
def version
|
|
14
|
+
SCHEMA_VERSION
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The schema as a Hash, freshly parsed on every call.
|
|
18
|
+
def document
|
|
19
|
+
::JSON.parse(File.read(PATH, encoding: Encoding::UTF_8))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|