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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +8 -0
  3. data/Gemfile +10 -0
  4. data/LICENSE.txt +17 -0
  5. data/README.md +180 -0
  6. data/Rakefile +38 -0
  7. data/doc/Jekyll/AgentAudit/BuildInventory.md +29 -0
  8. data/doc/Jekyll/AgentAudit/Configuration.md +42 -0
  9. data/doc/Jekyll/AgentAudit/ConfigurationError.md +6 -0
  10. data/doc/Jekyll/AgentAudit/Error.md +6 -0
  11. data/doc/Jekyll/AgentAudit/Extractor.md +20 -0
  12. data/doc/Jekyll/AgentAudit/Finding.md +30 -0
  13. data/doc/Jekyll/AgentAudit/InputError.md +6 -0
  14. data/doc/Jekyll/AgentAudit/LinkGraph.md +26 -0
  15. data/doc/Jekyll/AgentAudit/Registry.md +38 -0
  16. data/doc/Jekyll/AgentAudit/Report.md +27 -0
  17. data/doc/Jekyll/AgentAudit/ReportError.md +6 -0
  18. data/doc/Jekyll/AgentAudit/Reporters/Console.md +19 -0
  19. data/doc/Jekyll/AgentAudit/Reporters/JSON.md +9 -0
  20. data/doc/Jekyll/AgentAudit/Reporters.md +5 -0
  21. data/doc/Jekyll/AgentAudit/Rules/Provenance.md +127 -0
  22. data/doc/Jekyll/AgentAudit/Rules/Publication.md +73 -0
  23. data/doc/Jekyll/AgentAudit/Rules.md +5 -0
  24. data/doc/Jekyll/AgentAudit/Runner.md +13 -0
  25. data/doc/Jekyll/AgentAudit/UrlResolver.md +13 -0
  26. data/doc/Jekyll/AgentAudit.md +35 -0
  27. data/doc/Jekyll/Commands/AgentAudit/CommandParser.md +9 -0
  28. data/doc/Jekyll/Commands/AgentAudit/ParserErrors.md +9 -0
  29. data/doc/Jekyll/Commands/AgentAudit.md +13 -0
  30. data/doc/Jekyll/Commands.md +5 -0
  31. data/doc/Jekyll.md +5 -0
  32. data/doc/README.md +180 -0
  33. data/doc/index.csv +154 -0
  34. data/docs/example-report.json +281 -0
  35. data/docs/implementation-plan.md +26 -0
  36. data/docs/limitations.md +31 -0
  37. data/docs/verification.md +52 -0
  38. data/lib/jekyll/agent_audit/build_inventory.rb +196 -0
  39. data/lib/jekyll/agent_audit/command.rb +179 -0
  40. data/lib/jekyll/agent_audit/configuration.rb +196 -0
  41. data/lib/jekyll/agent_audit/errors.rb +10 -0
  42. data/lib/jekyll/agent_audit/extractor.rb +230 -0
  43. data/lib/jekyll/agent_audit/finding.rb +57 -0
  44. data/lib/jekyll/agent_audit/link_graph.rb +82 -0
  45. data/lib/jekyll/agent_audit/registry.rb +133 -0
  46. data/lib/jekyll/agent_audit/report.rb +79 -0
  47. data/lib/jekyll/agent_audit/reporters/console.rb +144 -0
  48. data/lib/jekyll/agent_audit/reporters/json.rb +16 -0
  49. data/lib/jekyll/agent_audit/rules/provenance.rb +412 -0
  50. data/lib/jekyll/agent_audit/rules/publication.rb +186 -0
  51. data/lib/jekyll/agent_audit/runner.rb +266 -0
  52. data/lib/jekyll/agent_audit/url_resolver.rb +98 -0
  53. data/lib/jekyll/agent_audit/version.rb +7 -0
  54. data/lib/jekyll-agent-audit.rb +24 -0
  55. data/llms.txt +35 -0
  56. data/schema/report-1.0.json +66 -0
  57. data/script/benchmark.rb +68 -0
  58. 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