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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module AgentAudit
5
+ module Reporters
6
+ module Console
7
+ module_function
8
+ SEVERITIES = %w[error warning info experimental].freeze
9
+ MAX_VALUE_LENGTH = 240
10
+
11
+ def render(report, verbose: false)
12
+ data = report.to_h
13
+ summary = data.fetch("summary", {})
14
+ lines = ["AI publishing audit — local build", "", "Documents inspected: #{safe(summary["documents_inspected"])}"]
15
+ lines << "Active findings: #{severity_summary(summary["active"])}"
16
+ lines << "Suppressed findings: #{safe(summary["suppressed"], "0")}"
17
+ lines << coverage_line(data.fetch("coverage", {}), data.dig("run", "complete"))
18
+ lines << ""
19
+ lines << "Discovery: Not assessed — jekyll-agent-discovery"
20
+ append_findings(lines, report.findings, verbose)
21
+ append_diagnostics(lines, data.fetch("diagnostics", []), verbose)
22
+ lines << ""
23
+ lines << result_line(report, data)
24
+ lines.join("\n")
25
+ end
26
+
27
+ def append_findings(lines, findings, verbose)
28
+ visible = findings.reject(&:suppressed?)
29
+ grouped = visible.group_by { |finding| [finding["category"].to_s, finding["rule_id"].to_s] }
30
+ grouped.sort_by { |(category, rule), _| [category, rule] }.each do |(category, rule), members|
31
+ lines << ""
32
+ lines << "#{label(category)} — #{escape(rule)}#{members.length > 1 ? " (#{members.length} occurrences)" : ""}"
33
+ members.each { |finding| append_finding(lines, finding, verbose) }
34
+ end
35
+ return unless verbose
36
+ findings.select(&:suppressed?).group_by { |finding| finding["rule_id"] }.each do |rule, members|
37
+ lines << ""
38
+ lines << "Suppressed — #{escape(rule)} (#{members.length})"
39
+ members.each { |finding| append_finding(lines, finding, true, suppressed: true) }
40
+ end
41
+ end
42
+
43
+ def append_finding(lines, finding, verbose, suppressed: false)
44
+ severity = finding["severity"].to_s.upcase
45
+ evidence = finding["evidence_level"]
46
+ lines << " #{severity} #{escape(finding["rule_id"])}#{evidence.to_s.empty? ? "" : " [#{escape(evidence)}]"}"
47
+ lines << " #{bounded(finding["message"])}"
48
+ lines << " URL: #{bounded(finding["document_url"])}" if finding["document_url"]
49
+ lines << " Source: #{location(finding["source_path"], finding["source_line"])}" if finding["source_path"] || finding["source_line"]
50
+ rendered = finding["rendered_location"] || {}
51
+ if rendered["path"] || rendered[:path]
52
+ lines << " Rendered: #{location(rendered["path"] || rendered[:path], rendered["line"] || rendered[:line])}"
53
+ end
54
+ observation = finding["observation"] || {}
55
+ lines << " Observation: #{bounded(format_value(observation))}" unless observation.empty?
56
+ lines << " Remediation: #{bounded(finding["remediation"])}" unless finding["remediation"].to_s.empty?
57
+ append_related(lines, finding["related_locations"]) unless Array(finding["related_locations"]).empty?
58
+ if suppressed
59
+ lines << " Suppression: #{bounded(format_value(finding["suppression"] || {}))}"
60
+ elsif verbose && !observation.empty?
61
+ lines << " Metadata: #{bounded(format_value(observation))}"
62
+ end
63
+ end
64
+
65
+ def append_related(lines, locations)
66
+ Array(locations).each do |related|
67
+ related = related.transform_keys(&:to_s) if related.respond_to?(:transform_keys)
68
+ path = related["path"] || related["source_path"] || related["url"] || related["entity"]
69
+ line = related["line"] || related["source_line"]
70
+ origin = related["origin"] ? " (#{escape(related["origin"])})" : ""
71
+ lines << " Related: #{location(path, line)}#{origin}"
72
+ end
73
+ end
74
+
75
+ def append_diagnostics(lines, diagnostics, verbose)
76
+ items = Array(diagnostics)
77
+ return if items.empty? && !verbose
78
+ lines << ""
79
+ lines << "Diagnostics#{items.empty? ? "" : " (#{items.length})"}:"
80
+ if items.empty?
81
+ lines << " None"
82
+ else
83
+ items.each do |diagnostic|
84
+ diagnostic = diagnostic.transform_keys(&:to_s) if diagnostic.respond_to?(:transform_keys)
85
+ code = diagnostic["code"] || "diagnostic"
86
+ message = diagnostic["message"] || format_value(diagnostic)
87
+ lines << " #{escape(code)}: #{bounded(message)}"
88
+ end
89
+ end
90
+ end
91
+
92
+ def coverage_line(coverage, run_complete = true)
93
+ rules = coverage.fetch("rules", {})
94
+ incomplete = rules.values.count { |item| item["status"].to_s == "incomplete" || item[:status].to_s == "incomplete" }
95
+ skipped = rules.values.sum { |item| (item["skipped"] || item[:skipped] || 0).to_i }
96
+ state = !run_complete || !incomplete.zero? ? "incomplete" : "complete"
97
+ "Coverage: #{state} for enabled checks; #{skipped} applications skipped"
98
+ end
99
+
100
+ def result_line(report, data)
101
+ code = Array(data["diagnostics"]).any? { |item| (item["code"] || item[:code]).to_s == "interrupted" } ? 130 : report.exit_code
102
+ result = {0 => "passed", 1 => "failed policy", 130 => "interrupted"}.fetch(code, "incomplete")
103
+ "Result: #{result} (fail_on=#{escape(data.dig("policy", "fail_on"))}). Exit #{code}."
104
+ end
105
+
106
+ def severity_summary(active)
107
+ active = active || {}
108
+ SEVERITIES.map { |severity| "#{safe(active[severity], "0")} #{severity}" }.join(", ")
109
+ end
110
+
111
+ def location(path, line_number)
112
+ value = bounded(path || "unknown")
113
+ line_number ? "#{value}:#{safe(line_number)}" : value
114
+ end
115
+
116
+ def label(value)
117
+ value.to_s.empty? ? "Findings" : value.to_s.capitalize
118
+ end
119
+
120
+ def bounded(value)
121
+ escape(value.to_s)[0, MAX_VALUE_LENGTH]
122
+ end
123
+
124
+ def format_value(value)
125
+ case value
126
+ when Hash then value.map { |key, item| "#{key}=#{format_value(item)}" }.join(", ")
127
+ when Array then value.map { |item| format_value(item) }.join(", ")
128
+ else value.to_s
129
+ end
130
+ end
131
+
132
+ def safe(value, fallback = "")
133
+ value.nil? ? fallback : escape(value)
134
+ end
135
+
136
+ def escape(value)
137
+ value.to_s.gsub(/[\x00-\x1f\x7f]/) { |char| char == "\e" ? "\\e" : "\\x%02x" % char.ord }
138
+ end
139
+ private_class_method :append_diagnostics, :append_finding, :append_findings, :append_related, :bounded,
140
+ :coverage_line, :escape, :format_value, :label, :location, :safe, :severity_summary
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Jekyll
6
+ module AgentAudit
7
+ module Reporters
8
+ module JSON
9
+ module_function
10
+ def render(report)
11
+ ::JSON.generate(report.to_h)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require "set"
6
+ require "time"
7
+
8
+ module Jekyll
9
+ module AgentAudit
10
+ module Rules
11
+ module Provenance
12
+ ARTICLE_TYPES = %w[Article BlogPosting NewsArticle].freeze
13
+ INTENT_KEYS = {
14
+ published: [[:date], ["date"]],
15
+ modified: [[:seo, :date_modified], ["seo", "date_modified"], [:last_modified_at], ["last_modified_at"]],
16
+ author: [[:author], ["author"]]
17
+ }.freeze
18
+
19
+ module_function
20
+
21
+ def evaluate(id, document, context)
22
+ return {status: :not_applicable, findings: [], reason: "unknown rule"} unless %w[
23
+ provenance.jsonld_invalid provenance.date_invalid provenance.date_order provenance.metadata_conflict
24
+ ].include?(id)
25
+
26
+ return {status: :not_applicable, findings: [], reason: "no document"} unless document
27
+
28
+ if %w[provenance.date_order provenance.metadata_conflict].include?(id) && ambiguous_article?(document, context)
29
+ return {status: :skipped, findings: [], reason: "ambiguous primary Article entity"}
30
+ end
31
+ if id == "provenance.date_order"
32
+ dates = provenance_sources(document, context)
33
+ unless dates[:published].any? { |source| source[:explicit] } && dates[:modified].any? { |source| source[:explicit] }
34
+ return {status: :skipped, findings: [], reason: "no comparable explicit publication and modification dates"}
35
+ end
36
+ end
37
+ if id == "provenance.metadata_conflict" && !metadata_comparable?(document, context)
38
+ return {status: :skipped, findings: [], reason: "no comparable provenance metadata pair"}
39
+ end
40
+ findings = case id
41
+ when "provenance.jsonld_invalid" then invalid_jsonld(document)
42
+ when "provenance.date_invalid" then invalid_dates(document, context)
43
+ when "provenance.date_order" then date_order(document, context)
44
+ when "provenance.metadata_conflict" then metadata_conflict(document, context)
45
+ end
46
+ {status: :evaluated, findings: findings}
47
+ end
48
+
49
+ def invalid_jsonld(document)
50
+ scripts(document).map do |script|
51
+ JSON.parse(script[:text] || script["text"])
52
+ nil
53
+ rescue JSON::ParserError => e
54
+ draft(document, "Invalid JSON in an application/ld+json script.",
55
+ {error: e.message.to_s.gsub(/[\x00-\x1f]/, " ")[0, 200], source: :jsonld}, line(script))
56
+ end.compact
57
+ end
58
+
59
+ def invalid_dates(document, context)
60
+ sources = provenance_sources(document, context)
61
+ sources.map do |field, values|
62
+ values.map do |source|
63
+ next unless source[:explicit]
64
+ next if parse_date(source[:value], context)
65
+ next if timezone_free_timestamp?(source[:value], context)
66
+
67
+ draft(document, "#{field} contains an unparseable explicit machine date.",
68
+ {field: field, value: source[:value], origin: source[:origin]}, source[:line])
69
+ end.compact
70
+ end.flatten
71
+ end
72
+
73
+ def date_order(document, context)
74
+ sources = provenance_sources(document, context)
75
+ published = comparable_date(sources[:published], context)
76
+ modified = comparable_date(sources[:modified], context)
77
+ return [] unless published && modified
78
+ return [] unless interval_before?(modified[:interval], published[:interval])
79
+
80
+ [draft(document, "Explicit dateModified precedes datePublished for the primary article.", {
81
+ published: published[:value], modified: modified[:value],
82
+ published_origin: published[:origin], modified_origin: modified[:origin]
83
+ }, modified[:line])]
84
+ end
85
+
86
+ def metadata_conflict(document, context)
87
+ sources = provenance_sources(document, context)
88
+ findings = []
89
+ %i[published modified].each do |field|
90
+ parsed = sources[field].map do |source|
91
+ next unless source[:explicit]
92
+ value = parse_date(source[:value], context)
93
+ value && source.merge(interval: value)
94
+ end.compact
95
+ next if parsed.length < 2
96
+ next unless parsed.combination(2).any? { |a, b| disjoint?(a[:interval], b[:interval]) }
97
+
98
+ findings << draft(document, "Explicit #{field} metadata values conflict.", {
99
+ field: field, values: parsed.map { |value| source_observation(value) }
100
+ }, parsed.last[:line], locations_for(parsed))
101
+ end
102
+
103
+ author_sets = author_sources(document, context).map do |source|
104
+ ids = author_set(source[:value])
105
+ ids.empty? ? nil : source.merge(set: ids, identity_kind: author_identity_kind(source[:value]))
106
+ end.compact
107
+ if author_sets.length >= 2 && author_sets.map { |source| source[:identity_kind] }.uniq.length == 1 &&
108
+ author_sets.combination(2).any? { |a, b| a[:set] != b[:set] }
109
+ findings << draft(document, "Explicit author metadata values conflict.", {
110
+ field: :author, values: author_sets.map { |source| source_observation(source).merge(authors: source[:set].to_a.sort) }
111
+ }, author_sets.last[:line], locations_for(author_sets))
112
+ end
113
+ publisher_sets = publisher_sources(document, context).map do |source|
114
+ set = author_set(source[:value])
115
+ set.empty? ? nil : source.merge(set: set, identity_kind: author_identity_kind(source[:value]))
116
+ end.compact
117
+ if publisher_sets.length >= 2 && publisher_sets.map { |source| source[:identity_kind] }.uniq.length == 1 &&
118
+ publisher_sets.combination(2).any? { |a, b| a[:set] != b[:set] }
119
+ findings << draft(document, "Explicit publisher metadata values conflict.", {
120
+ field: :publisher, values: publisher_sets.map { |source| source_observation(source).merge(publishers: source[:set].to_a.sort) }
121
+ }, publisher_sets.last[:line], locations_for(publisher_sets))
122
+ end
123
+ findings
124
+ end
125
+
126
+ def metadata_comparable?(document, context)
127
+ sources = provenance_sources(document, context)
128
+ sources.values.any? { |values| values.count { |source| source[:explicit] } >= 2 } ||
129
+ author_sources(document, context).count { |source| source[:explicit] } >= 2 ||
130
+ publisher_sources(document, context).count { |source| source[:explicit] } >= 2
131
+ end
132
+
133
+ def provenance_sources(document, context)
134
+ graph = primary_article(document, context)
135
+ values = {published: [], modified: []}
136
+ values[:published].concat(explicit_sources(document, :published))
137
+ values[:modified].concat(explicit_sources(document, :modified))
138
+ values[:published].concat(metadata_sources(document, :published))
139
+ values[:modified].concat(metadata_sources(document, :modified))
140
+ if graph
141
+ values[:published] << source(graph["datePublished"], :jsonld, graph["@id"], graph["__audit_line"])
142
+ modified = source(graph["dateModified"], :jsonld, graph["@id"], graph["__audit_line"])
143
+ modified[:explicit] = false if modified && seo_fallback_date?(graph, document, context)
144
+ values[:modified] << modified
145
+ end
146
+ infer_seo_rendered_fallback!(values, document, context)
147
+ values.transform_values { |items| items.compact }
148
+ end
149
+
150
+ def infer_seo_rendered_fallback!(values, document, context)
151
+ return unless seo_plugin_active?(context) && explicit_sources(document, :modified).empty?
152
+ published = values[:published].map { |item| parse_date(item[:value], context) }.compact
153
+ return if published.empty?
154
+ values[:modified].each do |modified|
155
+ parsed = parse_date(modified[:value], context)
156
+ modified[:explicit] = false if parsed && published.any? { |interval| overlaps?(interval, parsed) }
157
+ end
158
+ end
159
+
160
+ def author_sources(document, context)
161
+ sources = explicit_sources(document, :author) + metadata_sources(document, :author)
162
+ graph = primary_article(document, context)
163
+ return sources unless graph
164
+ author_reference = graph["author"]
165
+ author = resolved_value(author_reference, document, context)
166
+ entity = author.is_a?(Hash) ? (author["@id"] || author_reference["@id"] || graph["@id"]) : graph["@id"]
167
+ sources << source(author, :jsonld, entity, graph["__audit_line"]) if author
168
+ sources
169
+ end
170
+
171
+ def publisher_sources(document, context)
172
+ sources = metadata_sources(document, :publisher)
173
+ graph = primary_article(document, context)
174
+ return sources unless graph
175
+ publisher_reference = graph["publisher"]
176
+ publisher = resolved_value(publisher_reference, document, context)
177
+ if publisher
178
+ value = publisher.is_a?(Hash) && publisher["name"] ? publisher["name"] : publisher
179
+ entity = publisher.is_a?(Hash) ? (publisher["@id"] || publisher_reference["@id"] || graph["@id"]) : graph["@id"]
180
+ sources << source(value, :jsonld, entity, graph["__audit_line"])
181
+ end
182
+ sources
183
+ end
184
+
185
+ def explicit_sources(document, field)
186
+ value, present = intent_value(document[:explicit_data] || document["explicit_data"], field)
187
+ return [] unless present
188
+ [source(value, :explicit, nil)]
189
+ end
190
+
191
+ def metadata_sources(document, field)
192
+ nodes = document[:metadata_nodes] || document["metadata_nodes"] || {}
193
+ candidates = Array(nodes[field] || nodes[field.to_s])
194
+ if field == :author && candidates.length > 1
195
+ values = candidates.map { |candidate| candidate.is_a?(Hash) ? (candidate[:value] || candidate["value"]) : candidate }
196
+ first = candidates.first
197
+ return [source(values, first.is_a?(Hash) ? (first[:origin] || first["origin"] || :rendered) : :rendered,
198
+ nil, first.is_a?(Hash) ? (first[:line] || first["line"]) : nil)]
199
+ end
200
+ candidates.map do |candidate|
201
+ candidate = {value: candidate} unless candidate.is_a?(Hash)
202
+ source(candidate[:value] || candidate["value"], candidate[:origin] || candidate["origin"] || :rendered, nil,
203
+ candidate[:line] || candidate["line"]).tap do |entry|
204
+ entry[:entity] = candidate[:entity] || candidate["entity"] if entry
205
+ end
206
+ end.compact
207
+ end
208
+
209
+ def primary_article(document, context)
210
+ nodes = jsonld_nodes(document)
211
+ articles = nodes.values.select { |node| article?(node) }
212
+ return nil if articles.empty?
213
+ url = document[:url] || document["url"]
214
+ canonical = canonical_urls(document, context)
215
+ matches = articles.select do |article|
216
+ [article["url"], article["mainEntityOfPage"]].any? do |candidate|
217
+ candidate = candidate["@id"] if candidate.is_a?(Hash)
218
+ candidate && [url, *canonical].compact.include?(candidate)
219
+ end
220
+ end
221
+ return matches.first if matches.length == 1
222
+ return nil if articles.length == 1 && (articles.first["url"] || articles.first["mainEntityOfPage"])
223
+ articles.length == 1 ? articles.first : nil
224
+ end
225
+
226
+ def ambiguous_article?(document, context)
227
+ nodes = jsonld_nodes(document)
228
+ articles = nodes.values.select { |node| article?(node) }
229
+ return false if articles.length < 2
230
+ url = document[:url] || document["url"]
231
+ canonical = canonical_urls(document, context)
232
+ articles.count do |article|
233
+ [article["url"], article["mainEntityOfPage"]].any? do |candidate|
234
+ candidate = candidate["@id"] if candidate.is_a?(Hash)
235
+ candidate && [url, *canonical].include?(candidate)
236
+ end
237
+ end != 1
238
+ end
239
+
240
+ def jsonld_nodes(document)
241
+ scripts(document).each_with_object({}) do |script, result|
242
+ begin
243
+ collect_nodes(JSON.parse(script[:text] || script["text"]), result, line(script))
244
+ rescue JSON::ParserError
245
+ next
246
+ end
247
+ end
248
+ end
249
+
250
+ def collect_nodes(value, result, line_number = nil)
251
+ case value
252
+ when Array then value.each { |item| collect_nodes(item, result, line_number) }
253
+ when Hash
254
+ if value["@graph"]
255
+ collect_nodes(value["@graph"], result, line_number)
256
+ elsif value["@id"]
257
+ result[value["@id"]] = value.merge("__audit_line" => line_number)
258
+ elsif value["@type"]
259
+ result["__anonymous_#{result.length}"] = value.merge("__audit_line" => line_number)
260
+ end
261
+ end
262
+ end
263
+
264
+ def resolved_value(value, document, context)
265
+ return value unless value.is_a?(Hash) && value["@id"]
266
+ jsonld_nodes(document)[value["@id"]]
267
+ end
268
+
269
+ def author_set(value)
270
+ authors = value.is_a?(Array) ? value : [value]
271
+ authors.map do |author|
272
+ author = author[:value] if author.is_a?(Hash) && author.key?(:value)
273
+ if author.is_a?(Hash)
274
+ author["@id"] || author["url"] || normalize_name(author["name"])
275
+ else
276
+ normalize_name(author)
277
+ end
278
+ end.compact.to_set
279
+ end
280
+
281
+ def author_identity_kind(value)
282
+ authors = value.is_a?(Array) ? value : [value]
283
+ authors.any? { |author| author.is_a?(Hash) && (author["@id"] || author["url"]) } ? :id : :name
284
+ end
285
+
286
+ def canonical_urls(document, context)
287
+ Array(document[:canonicals] || document["canonicals"]).map do |item|
288
+ href = item[:url] || item["url"] || item[:href] || item["href"]
289
+ next href unless href && context[:resolver]
290
+ resolved = context[:resolver].resolve(href, document)
291
+ resolved.is_a?(Hash) ? (resolved[:url] || resolved["url"]) : nil
292
+ end.compact
293
+ end
294
+
295
+ def parse_date(value, context)
296
+ return nil unless value.is_a?(String) || value.is_a?(Date) || value.is_a?(Time) || value.is_a?(DateTime)
297
+ if value.is_a?(Date) && !value.is_a?(DateTime)
298
+ next_date = value + 1
299
+ return [Time.utc(value.year, value.month, value.day), Time.utc(next_date.year, next_date.month, next_date.day)]
300
+ end
301
+ if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
302
+ date = Date.parse(value)
303
+ next_date = date + 1
304
+ return [Time.utc(date.year, date.month, date.day), Time.utc(next_date.year, next_date.month, next_date.day)]
305
+ end
306
+ return [value.utc, value.utc] if value.respond_to?(:utc)
307
+ return nil unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}T/)
308
+ return nil if value.match?(/T[^Z+\-]*\z/) && timezone(context).nil?
309
+ timestamp = value
310
+ if timestamp.match?(/T[^Z+\-]*\z/)
311
+ offset = timezone(context)
312
+ return nil unless offset.is_a?(String) && offset.match?(/\A[+-]\d{2}:?\d{2}\z/)
313
+ timestamp = "#{timestamp}#{offset}"
314
+ end
315
+ instant = Time.iso8601(timestamp)
316
+ [instant, instant]
317
+ rescue ArgumentError, Date::Error
318
+ nil
319
+ end
320
+
321
+ def comparable_date(values, context)
322
+ values.map { |item| next unless item[:explicit]; parsed = parse_date(item[:value], context); parsed && item.merge(interval: parsed) }.compact.first
323
+ end
324
+
325
+ def disjoint?(a, b)
326
+ !overlaps?(a, b)
327
+ end
328
+
329
+ def overlaps?(a, b)
330
+ a_point = a[0] == a[1]
331
+ b_point = b[0] == b[1]
332
+ return a[0] == b[0] if a_point && b_point
333
+ return a[0] >= b[0] && a[0] < b[1] if a_point
334
+ return b[0] >= a[0] && b[0] < a[1] if b_point
335
+ a[0] < b[1] && b[0] < a[1]
336
+ end
337
+
338
+ def interval_before?(a, b)
339
+ return a[0] < b[0] if a[0] == a[1]
340
+ return a[1] <= b[0] if b[0] == b[1]
341
+ a[1] <= b[0]
342
+ end
343
+
344
+ def intent_value(data, field)
345
+ return [nil, false] unless data.is_a?(Hash)
346
+ paths = INTENT_KEYS[field] || []
347
+ paths.each do |path|
348
+ current = data
349
+ path.each do |key|
350
+ current = current[key] || current[key.to_s] if current.is_a?(Hash)
351
+ end
352
+ return [current, true] unless current.nil?
353
+ end
354
+ [nil, false]
355
+ end
356
+
357
+ def source(value, origin, entity, line = nil)
358
+ return nil if value.nil?
359
+ {value: value, origin: origin, entity: entity, line: line, explicit: true}
360
+ end
361
+
362
+ def seo_fallback_date?(graph, document, context)
363
+ return false unless seo_plugin_active?(context)
364
+ return false if explicit_sources(document, :modified).any?
365
+ graph["datePublished"] && graph["dateModified"] && graph["datePublished"] == graph["dateModified"]
366
+ end
367
+
368
+ def seo_plugin_active?(context)
369
+ plugins = context[:plugins] || context.dig(:site_config, :plugins) || context.dig(:site_config, "plugins") || []
370
+ Array(plugins).any? { |plugin| plugin.to_s == "jekyll-seo-tag" }
371
+ end
372
+
373
+ def source_observation(source)
374
+ {value: source[:value], origin: source[:origin], entity: source[:entity], line: source[:line]}
375
+ end
376
+
377
+ def locations_for(sources)
378
+ sources.map { |source| {line: source[:line], entity: source[:entity], origin: source[:origin]} }
379
+ end
380
+
381
+ def scripts(document)
382
+ Array(document[:jsonld_scripts] || document["jsonld_scripts"])
383
+ end
384
+
385
+ def line(script)
386
+ script[:line] || script["line"]
387
+ end
388
+
389
+ def article?(node)
390
+ Array(node["@type"]).any? { |type| ARTICLE_TYPES.include?(type) }
391
+ end
392
+
393
+ def normalize_name(value)
394
+ value.to_s.strip.gsub(/\s+/, " ") unless value.nil?
395
+ end
396
+
397
+ def timezone(context)
398
+ context.dig(:site_config, :timezone) || context.dig(:site_config, "timezone")
399
+ end
400
+
401
+ def timezone_free_timestamp?(value, context)
402
+ value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}T/) &&
403
+ value.match?(/T[^Z+\-]*\z/) && timezone(context).nil?
404
+ end
405
+
406
+ def draft(document, message, observation, line = nil, related_locations = [])
407
+ {document: document, message: message, observation: observation, line: line, related_locations: related_locations}
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end