jekyll-agent-audit 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/CHANGELOG.md +8 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +17 -0
- data/README.md +180 -0
- data/Rakefile +38 -0
- data/doc/Jekyll/AgentAudit/BuildInventory.md +29 -0
- data/doc/Jekyll/AgentAudit/Configuration.md +42 -0
- data/doc/Jekyll/AgentAudit/ConfigurationError.md +6 -0
- data/doc/Jekyll/AgentAudit/Error.md +6 -0
- data/doc/Jekyll/AgentAudit/Extractor.md +20 -0
- data/doc/Jekyll/AgentAudit/Finding.md +30 -0
- data/doc/Jekyll/AgentAudit/InputError.md +6 -0
- data/doc/Jekyll/AgentAudit/LinkGraph.md +26 -0
- data/doc/Jekyll/AgentAudit/Registry.md +38 -0
- data/doc/Jekyll/AgentAudit/Report.md +27 -0
- data/doc/Jekyll/AgentAudit/ReportError.md +6 -0
- data/doc/Jekyll/AgentAudit/Reporters/Console.md +19 -0
- data/doc/Jekyll/AgentAudit/Reporters/JSON.md +9 -0
- data/doc/Jekyll/AgentAudit/Reporters.md +5 -0
- data/doc/Jekyll/AgentAudit/Rules/Provenance.md +127 -0
- data/doc/Jekyll/AgentAudit/Rules/Publication.md +73 -0
- data/doc/Jekyll/AgentAudit/Rules.md +5 -0
- data/doc/Jekyll/AgentAudit/Runner.md +13 -0
- data/doc/Jekyll/AgentAudit/UrlResolver.md +13 -0
- data/doc/Jekyll/AgentAudit.md +35 -0
- data/doc/Jekyll/Commands/AgentAudit/CommandParser.md +9 -0
- data/doc/Jekyll/Commands/AgentAudit/ParserErrors.md +9 -0
- data/doc/Jekyll/Commands/AgentAudit.md +13 -0
- data/doc/Jekyll/Commands.md +5 -0
- data/doc/Jekyll.md +5 -0
- data/doc/README.md +180 -0
- data/doc/index.csv +154 -0
- data/docs/example-report.json +281 -0
- data/docs/implementation-plan.md +26 -0
- data/docs/limitations.md +31 -0
- data/docs/verification.md +52 -0
- data/lib/jekyll/agent_audit/build_inventory.rb +196 -0
- data/lib/jekyll/agent_audit/command.rb +179 -0
- data/lib/jekyll/agent_audit/configuration.rb +196 -0
- data/lib/jekyll/agent_audit/errors.rb +10 -0
- data/lib/jekyll/agent_audit/extractor.rb +230 -0
- data/lib/jekyll/agent_audit/finding.rb +57 -0
- data/lib/jekyll/agent_audit/link_graph.rb +82 -0
- data/lib/jekyll/agent_audit/registry.rb +133 -0
- data/lib/jekyll/agent_audit/report.rb +79 -0
- data/lib/jekyll/agent_audit/reporters/console.rb +144 -0
- data/lib/jekyll/agent_audit/reporters/json.rb +16 -0
- data/lib/jekyll/agent_audit/rules/provenance.rb +412 -0
- data/lib/jekyll/agent_audit/rules/publication.rb +186 -0
- data/lib/jekyll/agent_audit/runner.rb +266 -0
- data/lib/jekyll/agent_audit/url_resolver.rb +98 -0
- data/lib/jekyll/agent_audit/version.rb +7 -0
- data/lib/jekyll-agent-audit.rb +24 -0
- data/llms.txt +35 -0
- data/schema/report-1.0.json +66 -0
- data/script/benchmark.rb +68 -0
- metadata +133 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "nokogiri"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module AgentAudit
|
|
8
|
+
class InputError < StandardError; end unless const_defined?(:InputError)
|
|
9
|
+
|
|
10
|
+
class Extractor
|
|
11
|
+
MAX_NAME_BYTES = 512
|
|
12
|
+
MAX_EXCERPT_BYTES = 4_096
|
|
13
|
+
|
|
14
|
+
def initialize(configuration)
|
|
15
|
+
@configuration = configuration
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def extract(entry)
|
|
19
|
+
html = entry[:html] || read_artifact(entry[:absolute_path], entry[:destination_root])
|
|
20
|
+
raise InputError, "HTML exceeds max_html_bytes" if html.bytesize > limit(:max_html_bytes, 5 * 1024 * 1024)
|
|
21
|
+
|
|
22
|
+
doc = Nokogiri::HTML5.parse(html, nil, "UTF-8")
|
|
23
|
+
content = content_node(doc, entry)
|
|
24
|
+
id_nodes = doc.css("[id]")
|
|
25
|
+
id_index = id_nodes.each_with_object({}) { |node, index| index[node["id"].to_s] ||= node unless node["id"].to_s.empty? }
|
|
26
|
+
result = entry.dup.merge(
|
|
27
|
+
title: compact_text(node_text(doc.at_css("title"))),
|
|
28
|
+
ids: Set.new(id_index.keys), duplicate_ids: duplicate_ids(id_nodes), duplicate_id_locations: duplicate_id_locations(id_nodes),
|
|
29
|
+
links: [], canonicals: [], headings: [], base_href: doc.at_css("head base[href]")&.[]("href"),
|
|
30
|
+
content_basis: content[:basis], content_confident: content[:confident], redirect: redirect?(doc),
|
|
31
|
+
jsonld_scripts: [], metadata_nodes: Hash.new { |h, k| h[k] = [] }, diagnostics: content[:diagnostics]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
doc.css("a[href]").each do |node|
|
|
35
|
+
result[:links] << occurrence(node, accessible_name(node, id_index), :navigation, content[:basis])
|
|
36
|
+
end
|
|
37
|
+
doc.css("[cite]").each do |node|
|
|
38
|
+
href = node["cite"].to_s.strip
|
|
39
|
+
next if href.empty?
|
|
40
|
+
result[:links] << {href: href, line: node.line, name: compact_text(node_text(node)), name_supported: true,
|
|
41
|
+
hidden: hidden?(node), kind: :citation, region: :other}
|
|
42
|
+
end
|
|
43
|
+
doc.css("head link[href]").each do |node|
|
|
44
|
+
rels = node["rel"].to_s.split.map(&:downcase)
|
|
45
|
+
next if rels.include?("canonical")
|
|
46
|
+
kind = rels.include?("citation") ? :citation : (rels.include?("alternate") ? :alternate : nil)
|
|
47
|
+
next unless kind
|
|
48
|
+
result[:links] << {href: node["href"].to_s, line: node.line, name: compact_text(node["title"]),
|
|
49
|
+
name_supported: true, hidden: false, kind: kind, region: :other}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
content[:node]&.css("h1,h2,h3,h4,h5,h6,[role='heading']")&.each do |node|
|
|
53
|
+
next if excluded?(node) || decorative?(node)
|
|
54
|
+
level = node.name =~ /h([1-6])/ ? Regexp.last_match(1).to_i : node["aria-level"].to_i
|
|
55
|
+
next unless (1..6).cover?(level)
|
|
56
|
+
name = accessible_name(node, id_index)
|
|
57
|
+
result[:headings] << {level: level, name: name[:name], name_supported: name[:supported], hidden: hidden?(node), line: node.line}
|
|
58
|
+
end
|
|
59
|
+
doc.css("head link[rel]").each do |node|
|
|
60
|
+
rels = node["rel"].to_s.split.map(&:downcase)
|
|
61
|
+
result[:canonicals] << {href: node["href"], line: node.line} if rels.include?("canonical")
|
|
62
|
+
end
|
|
63
|
+
doc.css("script[type]").each do |node|
|
|
64
|
+
next unless node["type"].to_s.downcase == "application/ld+json"
|
|
65
|
+
raise InputError, "JSON-LD exceeds max_jsonld_bytes" if node.text.bytesize > limit(:max_jsonld_bytes, 1024 * 1024)
|
|
66
|
+
result[:jsonld_scripts] << {text: node.text, line: node.line}
|
|
67
|
+
end
|
|
68
|
+
result[:content_text] = compact_text(content_text(content[:node]), MAX_EXCERPT_BYTES)
|
|
69
|
+
extract_metadata(doc, result)
|
|
70
|
+
result
|
|
71
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
72
|
+
raise InputError, e.message
|
|
73
|
+
rescue Nokogiri::XML::SyntaxError, ArgumentError => e
|
|
74
|
+
raise InputError, "HTML could not be parsed: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def content_node(doc, entry)
|
|
80
|
+
selector = page_selector(entry) || config_value(:content, :selector)
|
|
81
|
+
diagnostics = []
|
|
82
|
+
if selector
|
|
83
|
+
matches = doc.css(selector.to_s)
|
|
84
|
+
return {node: matches.first, basis: :configured, confident: true, diagnostics: diagnostics} if matches.length == 1
|
|
85
|
+
diagnostics << {code: :content_selector_ambiguous, selector: selector.to_s, matches: matches.length}
|
|
86
|
+
return {node: nil, basis: :configured, confident: false, diagnostics: diagnostics}
|
|
87
|
+
end
|
|
88
|
+
mains = doc.css("main")
|
|
89
|
+
return {node: mains.first, basis: :main, confident: true, diagnostics: diagnostics} if mains.length == 1
|
|
90
|
+
landmarks = doc.css("[role='main']")
|
|
91
|
+
return {node: landmarks.first, basis: :main_role, confident: true, diagnostics: diagnostics} if landmarks.length == 1
|
|
92
|
+
articles = doc.css("article")
|
|
93
|
+
return {node: articles.first, basis: :article, confident: true, diagnostics: diagnostics} if articles.length == 1
|
|
94
|
+
{node: doc.at_css("body"), basis: :body, confident: false, diagnostics: diagnostics}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def page_selector(entry)
|
|
98
|
+
data = entry[:data] || {}
|
|
99
|
+
page = data["page"] || data[:page] || {}
|
|
100
|
+
audit = page["agent_audit"] || page[:agent_audit] || {}
|
|
101
|
+
content = audit["content"] || audit[:content] || {}
|
|
102
|
+
content["selector"] || content[:selector]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def content_text(node)
|
|
106
|
+
return "" unless node
|
|
107
|
+
copy = Nokogiri::HTML5.fragment(node.to_html)
|
|
108
|
+
exclusion_selectors.each do |selector|
|
|
109
|
+
copy.css(selector).each do |excluded|
|
|
110
|
+
excluded.add_previous_sibling(" ")
|
|
111
|
+
excluded.remove
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
copy.text.to_s
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def exclusion_selectors
|
|
118
|
+
configured = config_value(:content, :exclude_selectors)
|
|
119
|
+
configured.nil? ? %w[nav footer aside script style template] : Array(configured)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def occurrence(node, name, kind, basis)
|
|
123
|
+
{href: node["href"].to_s, line: node.line, name: name[:name], name_supported: name[:supported],
|
|
124
|
+
hidden: hidden?(node), kind: kind, region: region(node, basis)}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def duplicate_ids(nodes)
|
|
128
|
+
counts = Hash.new(0)
|
|
129
|
+
nodes.each { |node| counts[node["id"].to_s] += 1 unless node["id"].to_s.empty? }
|
|
130
|
+
counts.map { |id, count| id if count > 1 }.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def duplicate_id_locations(nodes)
|
|
134
|
+
locations = Hash.new { |hash, key| hash[key] = [] }
|
|
135
|
+
nodes.each do |node|
|
|
136
|
+
id = node["id"].to_s
|
|
137
|
+
locations[id] << node.line unless id.empty?
|
|
138
|
+
end
|
|
139
|
+
duplicate_ids(nodes).each_with_object({}) { |id, result| result[id] = locations[id] }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def read_artifact(path, destination_root = nil)
|
|
143
|
+
raise InputError, "missing artifact path" unless path && File.file?(path)
|
|
144
|
+
if destination_root
|
|
145
|
+
real = File.realpath(path)
|
|
146
|
+
root = File.realpath(destination_root)
|
|
147
|
+
raise InputError, "artifact escapes destination" unless real == root || real.start_with?(root + File::SEPARATOR)
|
|
148
|
+
end
|
|
149
|
+
File.binread(path)
|
|
150
|
+
rescue Errno::ELOOP, Errno::ENOENT, Errno::EACCES => e
|
|
151
|
+
raise InputError, e.message
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def extract_metadata(doc, result)
|
|
155
|
+
selectors = config_value(:metadata, :selectors) || {}
|
|
156
|
+
{published: :published, modified: :modified, author: :author, publisher: :publisher}.each do |field, key|
|
|
157
|
+
selector = selectors[key] || selectors[key.to_s]
|
|
158
|
+
next unless selector
|
|
159
|
+
nodes = doc.css(selector.to_s)
|
|
160
|
+
result[:diagnostics] << {code: :metadata_selector_ambiguous, field: field, matches: nodes.length} if %i[published modified].include?(field) && nodes.length > 1
|
|
161
|
+
nodes.each do |node|
|
|
162
|
+
value = node["datetime"].to_s.strip
|
|
163
|
+
next if value.empty?
|
|
164
|
+
result[:metadata_nodes][field] << {value: compact_text(value), line: node.line, origin: :rendered}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
doc.css("head meta[property]").each do |node|
|
|
168
|
+
field = {"article:published_time" => :published, "article:modified_time" => :modified}[node["property"].to_s.downcase]
|
|
169
|
+
next unless field && !node["content"].to_s.strip.empty?
|
|
170
|
+
result[:metadata_nodes][field] << {value: compact_text(node["content"].to_s.strip), line: node.line, origin: :open_graph}
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def accessible_name(node, id_index)
|
|
175
|
+
label = node["aria-label"].to_s.strip
|
|
176
|
+
return {name: compact_text(label), supported: true} unless label.empty?
|
|
177
|
+
if node["aria-labelledby"]
|
|
178
|
+
ids = node["aria-labelledby"].split
|
|
179
|
+
return {name: "", supported: false} if ids.empty? || ids.any? { |id| !id_index.key?(id) }
|
|
180
|
+
return {name: compact_text(ids.map { |id| node_text(id_index[id]) }.join(" ")), supported: true}
|
|
181
|
+
end
|
|
182
|
+
image_alt = node.at_css("img[alt]")&.[]("alt").to_s.strip
|
|
183
|
+
return {name: compact_text(image_alt), supported: true} unless image_alt.empty?
|
|
184
|
+
{name: compact_text(node_text(node)), supported: true}
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def excluded?(node)
|
|
188
|
+
exclusion_selectors.any? do |selector|
|
|
189
|
+
node.document.css(selector.to_s).any? { |candidate| candidate == node || node.ancestors.include?(candidate) }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def decorative?(node)
|
|
194
|
+
%w[presentation none].include?(node["role"].to_s.downcase)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def node_text(node)
|
|
198
|
+
node&.text.to_s.gsub(/\s+/, " ").strip
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def compact_text(value, max_bytes = MAX_NAME_BYTES)
|
|
202
|
+
text = value.to_s.unicode_normalize(:nfc).gsub(/\s+/, " ").strip
|
|
203
|
+
return text if text.bytesize <= max_bytes
|
|
204
|
+
text.byteslice(0, max_bytes).scrub.rstrip + "…"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def hidden?(node)
|
|
208
|
+
node.ancestors.any? { |ancestor| ancestor.key?("hidden") || ancestor["aria-hidden"].to_s.downcase == "true" } ||
|
|
209
|
+
node.key?("hidden") || node["aria-hidden"].to_s.downcase == "true"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def region(node, basis)
|
|
213
|
+
return :main if basis != :body && node.ancestors.any? { |n| n.name == "main" || n["role"] == "main" }
|
|
214
|
+
node.ancestors.any? { |n| n.name == "nav" } ? :navigation : :other
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def redirect?(doc)
|
|
218
|
+
doc.css("meta[http-equiv]").any? { |node| node["http-equiv"].to_s.casecmp("refresh").zero? }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def config_value(*keys)
|
|
222
|
+
keys.reduce(@configuration.respond_to?(:to_h) ? @configuration.to_h : @configuration) { |v, key| v.is_a?(Hash) ? (v[key] || v[key.to_s]) : nil }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def limit(key, default)
|
|
226
|
+
config_value(:limits, key) || default
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module AgentAudit
|
|
8
|
+
class Finding
|
|
9
|
+
SEVERITY_ORDER = {"error" => 0, "warning" => 1, "info" => 2, "experimental" => 3}.freeze
|
|
10
|
+
attr_reader :data
|
|
11
|
+
|
|
12
|
+
def initialize(draft = nil, registry: Registry, **attributes)
|
|
13
|
+
draft ||= attributes
|
|
14
|
+
draft = draft.transform_keys(&:to_sym)
|
|
15
|
+
rule = registry[draft.fetch(:rule_id)] || {}
|
|
16
|
+
observation = draft.fetch(:observation, {}).transform_keys(&:to_s)
|
|
17
|
+
@data = {
|
|
18
|
+
"rule_id" => draft.fetch(:rule_id).to_s, "rule_version" => rule.fetch(:version, "1"),
|
|
19
|
+
"category" => draft.fetch(:category, rule.fetch(:category, "unknown")).to_s,
|
|
20
|
+
"severity" => draft.fetch(:severity, rule.fetch(:default_severity, "info")).to_s,
|
|
21
|
+
"evidence_level" => draft.fetch(:evidence_level, rule.fetch(:evidence_level, "D")).to_s,
|
|
22
|
+
"confidence" => draft.fetch(:confidence, rule.fetch(:default_confidence, rule.fetch(:evidence_level, "A") == "A" ? "direct" : "heuristic")).to_s, "message" => draft.fetch(:message, "").to_s,
|
|
23
|
+
"extraction_basis" => draft[:extraction_basis],
|
|
24
|
+
"document_url" => draft[:document_url], "source_path" => draft[:source_path], "source_line" => draft[:source_line],
|
|
25
|
+
"rendered_location" => draft[:rendered_location] || {"path" => nil, "line" => draft[:line]}, "observation" => observation,
|
|
26
|
+
"remediation" => draft[:remediation] || rule[:remediation] || "", "evidence_urls" => Array(draft[:evidence_urls] || rule[:source_urls]),
|
|
27
|
+
"related_locations" => Array(draft[:related_locations]), "fingerprint" => draft[:fingerprint] || fingerprint(observation, draft),
|
|
28
|
+
"suppression" => draft[:suppression]
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def [](key) = @data[key.to_s]
|
|
33
|
+
def to_h = @data.dup
|
|
34
|
+
def severity_rank = SEVERITY_ORDER.fetch(self["severity"], 99)
|
|
35
|
+
def suppressed? = !self["suppression"].nil?
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def fingerprint(observation, draft)
|
|
40
|
+
stable = [draft[:rule_id], draft[:rule_version] || "1", draft[:document_identity] || draft[:document_url], stable_observation(observation)].to_json
|
|
41
|
+
Digest::SHA256.hexdigest(stable)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stable_observation(value, key = nil)
|
|
45
|
+
case value
|
|
46
|
+
when Hash
|
|
47
|
+
value.keys.sort.each_with_object({}) do |child_key, result|
|
|
48
|
+
next if %w[line source_line rendered_location absolute_path].include?(child_key.to_s)
|
|
49
|
+
result[child_key.to_s] = stable_observation(value[child_key], child_key)
|
|
50
|
+
end
|
|
51
|
+
when Array then value.map { |item| stable_observation(item) }
|
|
52
|
+
else value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module AgentAudit
|
|
8
|
+
class LinkGraph
|
|
9
|
+
attr_reader :diagnostics
|
|
10
|
+
|
|
11
|
+
def initialize(documents, resolver, configuration, site_config = {})
|
|
12
|
+
@documents, @resolver, @configuration, @site_config = Array(documents), resolver, configuration, site_config || {}
|
|
13
|
+
@incoming, @edges, @by_source, @diagnostics = Hash.new { |h, k| h[k] = Set.new }, [], Hash.new { |h, k| h[k] = [] }, []
|
|
14
|
+
build
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def incoming(document)
|
|
18
|
+
@incoming[document[:identity]]
|
|
19
|
+
end
|
|
20
|
+
def edges
|
|
21
|
+
@edges.dup
|
|
22
|
+
end
|
|
23
|
+
def edges_for(document)
|
|
24
|
+
@by_source[document[:identity]].dup
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reachable
|
|
28
|
+
roots = roots_config
|
|
29
|
+
found, queue = Set.new, roots.map { |root| @documents.find { |d| root_match?(d, root) } }.compact
|
|
30
|
+
until queue.empty?
|
|
31
|
+
current = queue.shift
|
|
32
|
+
next if found.include?(current[:identity])
|
|
33
|
+
found << current[:identity]
|
|
34
|
+
@edges.select { |edge| edge[:source][:identity] == current[:identity] && edge[:target] }.each { |edge| queue << edge[:target] }
|
|
35
|
+
end
|
|
36
|
+
found
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build
|
|
42
|
+
@documents.each do |document|
|
|
43
|
+
Array(document[:links]).each do |link|
|
|
44
|
+
resolution = @resolver.resolve(link[:href], document)
|
|
45
|
+
edge = {source: document, occurrence: link, resolution: resolution, target: resolution[:target], kind: link[:kind]}
|
|
46
|
+
@edges << edge
|
|
47
|
+
@by_source[document[:identity]] << edge
|
|
48
|
+
if resolution[:target] && link[:kind] == :navigation && resolution[:target][:identity] != document[:identity]
|
|
49
|
+
@incoming[resolution[:target][:identity]] << document
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
roots_config.each { |root| @diagnostics << {code: :invalid_graph_root, root: root} unless @documents.any? { |d| root_match?(d, root) } }
|
|
54
|
+
limit = config_limit(:max_edges, 1_000_000)
|
|
55
|
+
@diagnostics << {code: :edge_limit, limit: limit, value: @edges.length} if @edges.length > limit
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def roots_config
|
|
59
|
+
config = @configuration.respond_to?(:to_h) ? @configuration.to_h : @configuration
|
|
60
|
+
graph = config[:graph] || config["graph"] || {}
|
|
61
|
+
Array(graph[:roots] || graph["roots"] || ["/"]).map(&:to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def root_match?(document, root)
|
|
65
|
+
base = @site_config.fetch("baseurl", @site_config[:baseurl]).to_s.sub(%r{/\z}, "")
|
|
66
|
+
route = root == "/" && !base.empty? ? "#{base}/" : root
|
|
67
|
+
return true if document[:route].to_s == route || (root == "/" && document[:route].to_s == "/index.html")
|
|
68
|
+
begin
|
|
69
|
+
URI.parse(document[:url].to_s).path == route
|
|
70
|
+
rescue URI::InvalidURIError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def config_limit(key, default)
|
|
76
|
+
config = @configuration.respond_to?(:to_h) ? @configuration.to_h : @configuration
|
|
77
|
+
limits = config[:limits] || config["limits"] || {}
|
|
78
|
+
limits[key] || limits[key.to_s] || default
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module AgentAudit
|
|
5
|
+
module Registry
|
|
6
|
+
SEVERITIES = %w[error warning info experimental].freeze
|
|
7
|
+
SOURCE_URLS = {
|
|
8
|
+
"G-AI" => "https://developers.google.com/search/docs/appearance/ai-features",
|
|
9
|
+
"G-DATE" => "https://developers.google.com/search/docs/appearance/publication-dates",
|
|
10
|
+
"G-ARTICLE" => "https://developers.google.com/search/docs/appearance/structured-data/article",
|
|
11
|
+
"G-WHO" => "https://developers.google.com/search/docs/fundamentals/creating-helpful-content",
|
|
12
|
+
"G-LINK" => "https://developers.google.com/search/docs/crawling-indexing/links-crawlable",
|
|
13
|
+
"G-CAN" => "https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls",
|
|
14
|
+
"URI" => "https://www.rfc-editor.org/rfc/rfc3986",
|
|
15
|
+
"JSON" => "https://www.rfc-editor.org/rfc/rfc8259",
|
|
16
|
+
"CAN" => "https://www.rfc-editor.org/rfc/rfc6596.txt",
|
|
17
|
+
"HEAD" => "https://www.w3.org/WAI/tutorials/page-structure/headings/",
|
|
18
|
+
"LINK" => "https://www.w3.org/WAI/WCAG22/Techniques/html/H80",
|
|
19
|
+
"TABLE" => "https://www.w3.org/WAI/tutorials/tables/one-header/",
|
|
20
|
+
"G-2026" => "https://developers.google.com/search/docs/fundamentals/ai-optimization-guide",
|
|
21
|
+
"GEO" => "https://arxiv.org/abs/2311.09735",
|
|
22
|
+
"CSEO" => "https://arxiv.org/abs/2506.11097",
|
|
23
|
+
"AGEO" => "https://arxiv.org/abs/2603.09296"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
MVP_RULES = %w[
|
|
27
|
+
architecture.output_collision links.target_missing links.fragment_missing architecture.orphan
|
|
28
|
+
identity.canonical_invalid identity.canonical_multiple identity.canonical_target_missing
|
|
29
|
+
structure.title_missing structure.h1_absent structure.heading_empty structure.heading_jump
|
|
30
|
+
structure.id_duplicate structure.link_unnamed provenance.jsonld_invalid provenance.date_invalid
|
|
31
|
+
provenance.date_order provenance.metadata_conflict
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
DEFERRED_RULES = %w[
|
|
35
|
+
architecture.unreachable architecture.weak_connections duplicates.exact_body duplicates.near_body
|
|
36
|
+
identity.canonical_review identity.canonical_chain identity.canonical_absent structure.link_generic
|
|
37
|
+
structure.table_headers structure.long_section structure.main_landmark provenance.author_absent
|
|
38
|
+
provenance.author_profile_absent provenance.publication_absent provenance.modification_absent
|
|
39
|
+
provenance.publisher_absent citations.quote_attribution citations.sources_inventory representations.identity_conflict
|
|
40
|
+
retrieval.definition_candidates retrieval.concrete_fact_candidates retrieval.numeric_context
|
|
41
|
+
retrieval.comparison_candidates retrieval.procedure_candidates retrieval.example_candidates
|
|
42
|
+
retrieval.subject_context citations.claim_source_alignment provenance.originality_evidence
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
# [category, evidence, severity, source keys, applicability, required inputs, limitations, remediation]
|
|
46
|
+
DETAILS = {
|
|
47
|
+
"architecture.output_collision" => ["architecture", "A", "error", %w[URI], "site inventory and output ownership", %w[inventory output_paths public_routes], %w[aliases and directory_indexes can be intentional], "Give each distinct emitter a unique destination or document the ownership boundary."],
|
|
48
|
+
"links.target_missing" => ["architecture", "A", "error", %w[URI G-LINK], "rendered links and managed local targets", %w[html_links complete_manifest url_resolver], %w[external and deployment_owned targets are unverified], "Correct the href or declare the target outside the managed build."],
|
|
49
|
+
"links.fragment_missing" => ["architecture", "A", "error", %w[URI], "managed local HTML links with fragments", %w[html_links target_ids named_anchors], %w[empty/top/text_fragments and unsupported targets are skipped], "Correct the fragment or add the referenced ID/name anchor."],
|
|
50
|
+
"architecture.orphan" => ["architecture", "B", "warning", %w[G-LINK], "non-root rendered content pages", %w[html_navigation_links complete_inventory], %w[self_links redirects and explicit_exemptions are excluded], "Add an incoming rendered navigation link or declare the page exemption."],
|
|
51
|
+
"identity.canonical_invalid" => ["identity", "A", "error", %w[URI CAN], "head canonical declarations", %w[canonical_hrefs uri_parser], %w[valid relative empty_self external or unsupported_iri cases are not rejected], "Remove the malformed canonical or replace it with a valid URI reference."],
|
|
52
|
+
"identity.canonical_multiple" => ["identity", "A", "error", %w[CAN G-CAN], "documents with canonical declarations", %w[all_canonical_hrefs url_resolver], %w[identical repeated declarations collapse], "Keep one canonical URL, or make repeated declarations identical."],
|
|
53
|
+
"identity.canonical_target_missing" => ["identity", "A", "error", %w[CAN], "managed local canonical targets", %w[canonical_uri local_inventory], %w[external targets remain unverified], "Correct the canonical target or publish the managed target."],
|
|
54
|
+
"structure.title_missing" => ["structure", "B", "warning", %w[G-AI], "rendered HTML documents", %w[rendered_title], %w[front_matter alone does not satisfy the rendered check], "Render a nonempty title for the document."],
|
|
55
|
+
"structure.h1_absent" => ["structure", "C", "info", %w[HEAD], "confidently extracted content pages", %w[content_extraction heading_tree], %w[ambiguous or body_fallback extraction skips the content-sensitive check], "Review the content selector or render a level-one heading."],
|
|
56
|
+
"structure.heading_empty" => ["structure", "B", "warning", %w[HEAD], "content headings", %w[heading_text accessible_name visibility], %w[decorative hidden and complex naming cases are skipped], "Give the heading text or a supported accessible name."],
|
|
57
|
+
"structure.heading_jump" => ["structure", "C", "info", %w[HEAD], "confidently extracted heading sequences", %w[ordered_content_headings], %w[closing subsections and ambiguous extraction are excluded], "Review the heading hierarchy around the reported pair."],
|
|
58
|
+
"structure.id_duplicate" => ["structure", "A", "error", %w[URI], "rendered documents", %w[element_ids named_anchors], %w[only repeated nonempty identifiers are reported], "Make each fragment identifier unique within the document."],
|
|
59
|
+
"structure.link_unnamed" => ["structure", "B", "warning", %w[LINK G-LINK], "navigational anchors", %w[anchor_text image_alt aria_name], %w[complex unsupported accessible naming is skipped], "Add link text, an image alt, or a supported accessible name."],
|
|
60
|
+
"provenance.jsonld_invalid" => ["provenance", "A", "error", %w[JSON], "application/ld+json scripts", %w[script_text json_parser script_line], %w[valid unsupported JSON-LD is not an error], "Fix the JSON syntax in the reported structured-data script."],
|
|
61
|
+
"provenance.date_invalid" => ["provenance", "B", "warning", %w[G-DATE G-ARTICLE], "recognized explicit publication/modification fields", %w[explicit_data rendered_metadata primary_article machine_dates], %w[natural_language and unrelated Event dates are excluded], "Replace the value with a valid ISO date or timezone-aware timestamp."],
|
|
62
|
+
"provenance.date_order" => ["provenance", "B", "warning", %w[G-DATE G-ARTICLE], "an unambiguous primary Article with comparable dates", %w[article_identity publication_date modification_date], %w[date_only uncertainty intervals and timezone_free timestamps without configured timezone skip], "Check the metadata sources and correct the modification/publication ordering."],
|
|
63
|
+
"provenance.metadata_conflict" => ["provenance", "B", "warning", %w[G-DATE G-ARTICLE], "equivalent mapped provenance fields with at least two comparable sources", %w[explicit_data rendered_metadata primary_article], %w[aliases brand_suffixes unrelated_graph_nodes unresolved_references and compatible_precision are excluded], "Reconcile the conflicting declarations or remove the unsupported claim."],
|
|
64
|
+
"architecture.unreachable" => ["architecture", "B", "warning", %w[G-LINK], "complete directed HTML graph", %w[graph_roots complete_inventory navigation_edges], %w[deferred until graph reachability and root validity are shipped], "Add a path from a configured root or document the page as intentionally outside navigation."],
|
|
65
|
+
"architecture.weak_connections" => ["architecture", "C", "info", %w[G-LINK], "opt-in graph connection analysis", %w[incoming_navigation contextual_edges], %w[no minimum link count or ranking cutoff], "Review contextual links if the observed connection is unintended."],
|
|
66
|
+
"duplicates.exact_body" => ["duplicates", "C", "info", %w[G-CAN], "distinct page identities with comparable content", %w[normalized_main_content representation_exclusions], %w[duplicate text is an observation, not an automatic harmfulness claim], "Review whether the pages should remain distinct or declare an equivalent representation."],
|
|
67
|
+
"duplicates.near_body" => ["duplicates", "D", "experimental", %w[GEO CSEO AGEO], "explicitly configured lexical similarity analysis", %w[tokenizer threshold bounded_candidate_comparison], %w[heuristic candidates can miss pairs and do not imply plagiarism], "Review the reported overlap and its configured algorithm; do not auto-merge pages."],
|
|
68
|
+
"identity.canonical_review" => ["identity", "C", "info", %w[CAN G-CAN], "opt-in content pages with canonical declarations", %w[public_url canonical_url expected_canonical], %w[an external or superseding canonical is valid and difference alone is not a defect], "Confirm that the differing canonical expresses the intended syndication or consolidation."],
|
|
69
|
+
"identity.canonical_chain" => ["identity", "B", "warning", %w[CAN], "opt-in managed canonical graph", %w[local_canonical_edges cycle_detection], %w[external canonical edges are outside the local chain], "Point the page directly at the intended canonical target and remove the cycle."],
|
|
70
|
+
"identity.canonical_absent" => ["identity", "C", "info", %w[G-CAN], "opted-in content pages", %w[rendered_canonical_declarations], %w[HTTP-header canonicals are unknown], "Add a canonical if the publication contract calls for one."],
|
|
71
|
+
"structure.link_generic" => ["structure", "C", "info", %w[LINK G-LINK], "opt-in rendered navigational links", %w[link_label locale context], %w[locale-specific detection is contextual and not an accessibility failure], "Use a link label that identifies the destination in context."],
|
|
72
|
+
"structure.table_headers" => ["structure", "B", "warning", %w[TABLE], "opt-in author-declared data tables", %w[table_structure header_relationships], %w[layout and ambiguous tables are skipped], "Add explicit header relationships to the data table."],
|
|
73
|
+
"structure.long_section" => ["structure", "D", "experimental", %w[G-2026], "opt-in sections with an author-configured threshold", %w[section_boundaries tokenizer threshold], %w[no universal threshold or forced_chunking recommendation], "Review the measured section size and threshold as an editorial choice."],
|
|
74
|
+
"structure.main_landmark" => ["structure", "C", "info", %w[G-2026], "opt-in rendered pages without a confident content landmark", %w[main_landmarks content_selector], %w[the observation does not claim invalid HTML], "Configure the content selector or provide an unambiguous main landmark."],
|
|
75
|
+
"provenance.author_absent" => ["provenance", "C", "info", %w[G-WHO], "opted-in article profiles", %w[rendered_author_evidence explicit_vs_rendered_provenance], %w[front_matter_only evidence is reported as not rendered], "Render a recognized person or organization author when the profile contract requires one."],
|
|
76
|
+
"provenance.author_profile_absent" => ["provenance", "C", "info", %w[G-ARTICLE], "opted-in profiles with recognized authors", %w[author_profile_url sameAs linked_byline], %w[organizations and pseudonyms are valid; social links are not required], "Add a profile URL, sameAs relation, or linked byline where appropriate."],
|
|
77
|
+
"provenance.publication_absent" => ["provenance", "C", "info", %w[G-DATE], "opted-in article profiles", %w[explicit_publication_evidence], %w[generic pages and inferred collection timestamps are excluded], "Publish explicit publication date evidence if the profile requires it."],
|
|
78
|
+
"provenance.modification_absent" => ["provenance", "C", "info", %w[G-ARTICLE], "profiles explicitly opting into update metadata", %w[explicit_update_intent rendered_modification_field], %w[no default check for every article], "Render an explicit modification date or remove the update-metadata declaration."],
|
|
79
|
+
"provenance.publisher_absent" => ["provenance", "C", "info", %w[G-WHO], "opted-in profiles", %w[recognized_publisher configured_visible_site_identity], %w[do not infer a publisher from a domain or arbitrary site title], "Provide a recognized publisher identity when the profile requires one."],
|
|
80
|
+
"citations.quote_attribution" => ["citations", "C", "info", %w[G-WHO], "opt-in marked-up quotations", %w[quotation_markup cite_url visible_attribution exemptions], %w[attribution detection cannot prove correctness], "Add a source link or visible attribution where appropriate."],
|
|
81
|
+
"citations.sources_inventory" => ["citations", "C", "info", %w[G-LINK], "opt-in rendered documents", %w[reference_links citation_markup external_link_classification], %w[zero references is an observation, never unsupported_claims proof], "Review the source inventory as an editorial observation."],
|
|
82
|
+
"representations.identity_conflict" => ["representations", "B", "warning", %w[URI], "recognized sibling metadata representations", %w[advertised_sibling equivalent_identity_date_fields], %w[raw Markdown without metadata and unadvertised siblings are skipped], "Reconcile the sibling representation's explicit identity or date metadata."],
|
|
83
|
+
"retrieval.definition_candidates" => ["retrieval", "D", "experimental", %w[GEO CSEO AGEO], "opt-in semantic candidate inventory", %w[passage_text lexical_candidate_detector], %w[no definition quota or semantic-understanding claim], "Review candidates editorially; do not add definitions to satisfy a quota."],
|
|
84
|
+
"retrieval.concrete_fact_candidates" => ["retrieval", "D", "experimental", %w[GEO CSEO AGEO], "opt-in semantic candidate inventory", %w[passage_text candidate_detector], %w[cannot establish factuality or distinguish fiction without context], "Review candidates editorially; the detector does not verify facts."],
|
|
85
|
+
"retrieval.numeric_context" => ["retrieval", "D", "experimental", %w[GEO CSEO AGEO], "opt-in numeric-context inventory", %w[numbers units dates nearby_prose], %w[more numbers are not inherently better; preserve scope and uncertainty], "Review numerical context and scope editorially."],
|
|
86
|
+
"retrieval.comparison_candidates" => ["retrieval", "D", "experimental", %w[GEO CSEO AGEO], "opt-in comparison candidate inventory", %w[comparison_passage dimensions], %w[no comparison-section or table requirement], "Review whether the comparison is useful for readers."],
|
|
87
|
+
"retrieval.procedure_candidates" => ["retrieval", "D", "experimental", %w[GEO CSEO AGEO], "opt-in procedure candidate inventory", %w[ordered_instruction_candidates], %w[nonprocedural pages need no numbered list], "Review procedure candidates editorially."],
|
|
88
|
+
"retrieval.example_candidates" => ["retrieval", "D", "experimental", %w[GEO CSEO AGEO], "opt-in example/code candidate inventory", %w[labeled_examples code_samples], %w[no minimum example count], "Review whether examples clarify the subject."],
|
|
89
|
+
"retrieval.subject_context" => ["retrieval", "D", "experimental", %w[GEO CSEO AGEO], "opt-in passage context inventory", %w[passages heading_ancestry pronoun_candidates], %w[do not force repetition or an answer-first template], "Review isolated passages with their heading context."],
|
|
90
|
+
"citations.claim_source_alignment" => ["citations", "D", "experimental", %w[GEO CSEO AGEO], "opt-in claim/source candidate inventory", %w[claims nearby_links citation_context], %w[link proximity is not entailment or verification], "Review whether the cited source supports the claim."],
|
|
91
|
+
"provenance.originality_evidence" => ["provenance", "D", "experimental", %w[GEO CSEO AGEO], "opt-in declared originality evidence inventory", %w[declared_methods datasets observations], %w[declarations do not prove first-party origin; no originality score], "Review the declaration and supporting evidence editorially."]
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
class << self
|
|
95
|
+
def all
|
|
96
|
+
@all ||= begin
|
|
97
|
+
records = MVP_RULES.map { |id| build(id, "M") }
|
|
98
|
+
records.concat(DEFERRED_RULES.map { |id| build(id, "D") })
|
|
99
|
+
records.to_h { |entry| [entry[:id], entry] }.freeze
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def mvp_ids
|
|
104
|
+
MVP_RULES.freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def known?(id)
|
|
108
|
+
all.key?(id.to_s)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def deferred?(id)
|
|
112
|
+
all.fetch(id.to_s, {})[:release] == "D"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def [](id)
|
|
116
|
+
all[id.to_s]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def build(id, release)
|
|
122
|
+
category, evidence, severity, source_keys, applicability, inputs, limits, remediation = DETAILS.fetch(id)
|
|
123
|
+
{
|
|
124
|
+
id: id, version: "1.0", category: category, default_severity: severity,
|
|
125
|
+
evidence_level: evidence, source_urls: source_keys.map { |key| SOURCE_URLS.fetch(key) },
|
|
126
|
+
applicability: applicability, required_inputs: inputs, limitations: limits,
|
|
127
|
+
remediation: remediation, release: release
|
|
128
|
+
}.freeze
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Jekyll
|
|
6
|
+
module AgentAudit
|
|
7
|
+
class Report
|
|
8
|
+
SCHEMA_VERSION = "1.0"
|
|
9
|
+
attr_reader :data
|
|
10
|
+
|
|
11
|
+
def initialize(findings: [], coverage: {}, diagnostics: [], documents_inspected: 0, options: {}, site_config: {}, complete: true,
|
|
12
|
+
reference_time: Time.now.utc, operational_error: false, effective_configuration: nil, build: nil)
|
|
13
|
+
@findings = findings.map { |f| f.is_a?(Finding) ? f : Finding.new(f) }
|
|
14
|
+
@coverage = coverage
|
|
15
|
+
@diagnostics = diagnostics
|
|
16
|
+
@documents_inspected = documents_inspected
|
|
17
|
+
@options = options
|
|
18
|
+
@complete = complete
|
|
19
|
+
@site_config = site_config
|
|
20
|
+
@reference_time = reference_time
|
|
21
|
+
@operational_error = operational_error
|
|
22
|
+
@effective_configuration = effective_configuration
|
|
23
|
+
@build = build
|
|
24
|
+
@data = build_data
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def findings = @findings
|
|
28
|
+
def to_h = deep_dup(@data)
|
|
29
|
+
def exit_code
|
|
30
|
+
return 2 if @operational_error || !@complete
|
|
31
|
+
fail_on = @options.fetch("fail_on", "error")
|
|
32
|
+
return 0 if fail_on == "none"
|
|
33
|
+
return 1 if @findings.any? { |f| !f.suppressed? && f["severity"] == "error" }
|
|
34
|
+
return 1 if fail_on == "warning" && @findings.any? { |f| !f.suppressed? && f["severity"] == "warning" }
|
|
35
|
+
0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_data
|
|
41
|
+
active = Hash.new(0)
|
|
42
|
+
suppressed = 0
|
|
43
|
+
affected = {}
|
|
44
|
+
@findings.each do |finding|
|
|
45
|
+
if finding.suppressed?
|
|
46
|
+
suppressed += 1
|
|
47
|
+
else
|
|
48
|
+
active[finding["severity"]] += 1
|
|
49
|
+
affected[finding["document_url"]] = true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
{
|
|
53
|
+
"schema_version" => SCHEMA_VERSION, "tool" => {"name" => "jekyll-agent-audit", "version" => VERSION},
|
|
54
|
+
"run" => {"mode" => "local_build", "complete" => @complete, "site_url" => @site_config["url"].to_s,
|
|
55
|
+
"baseurl" => @site_config["baseurl"].to_s, "environment" => ENV.fetch("JEKYLL_ENV", "development"),
|
|
56
|
+
"reference_time" => @reference_time.iso8601, "ruleset_version" => VERSION,
|
|
57
|
+
"effective_configuration" => @effective_configuration, "build" => @build},
|
|
58
|
+
"policy" => {"fail_on" => @options.fetch("fail_on", "error"), "passed" => exit_code.zero?, "exit_code" => exit_code},
|
|
59
|
+
"summary" => {"documents_inspected" => @documents_inspected, "active" => %w[error warning info experimental].to_h { |s| [s, active[s]] },
|
|
60
|
+
"suppressed" => suppressed, "affected_documents" => affected.length},
|
|
61
|
+
"coverage" => {"discovery" => {"status" => "not_assessed", "owner" => "jekyll-agent-discovery"}, "rules" => @coverage},
|
|
62
|
+
"findings" => sorted_findings.map(&:to_h), "diagnostics" => @diagnostics
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def sorted_findings
|
|
67
|
+
@findings.sort_by { |f| [f["source_path"].to_s, f.severity_rank, f["rule_id"], f["rendered_location"].to_h["line"].to_i] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def deep_dup(value)
|
|
71
|
+
case value
|
|
72
|
+
when Hash then value.to_h { |k, v| [k, deep_dup(v)] }
|
|
73
|
+
when Array then value.map { |v| deep_dup(v) }
|
|
74
|
+
else value
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|