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.
@@ -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