84 lines
3.1 KiB
Ruby
84 lines
3.1 KiB
Ruby
require "json"
|
|
require "set"
|
|
|
|
module Adr
|
|
# Deterministic-first ADR retrieval. Layers, in order:
|
|
# 1. path/component match against affected-paths / affected-components
|
|
# 2. status filter (Accepted only unless all_statuses)
|
|
# 3. Graphify one-hop expansion of the query paths (when graphify-out/
|
|
# exists; degrades gracefully to layers 1-2 when absent or unreadable)
|
|
# AI judgment (layer 4) happens in the `find` skill, only over what this
|
|
# returns — never the full corpus.
|
|
class Finder
|
|
def initialize(repository:, graph_path: nil)
|
|
@repository = repository
|
|
@graph_path = graph_path
|
|
end
|
|
|
|
def find(paths: [], components: [], all_statuses: false)
|
|
paths = normalize(paths)
|
|
graph_paths = normalize(graph_neighbors(paths)) - paths
|
|
@repository.entries.filter_map do |entry|
|
|
record = entry.record
|
|
next unless all_statuses || record.status == "Accepted"
|
|
|
|
matched = match_reasons(record, paths, graph_paths, components)
|
|
next if matched.empty?
|
|
|
|
{ "id" => record.id, "title" => record.title, "status" => record.status,
|
|
"date" => record.date.to_s, "file" => entry.path, "matched_on" => matched }
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def match_reasons(record, paths, graph_paths, components)
|
|
affected_paths = Array(record.frontmatter["affected-paths"]).map { |p| p.to_s.chomp("/") }
|
|
affected_components = Array(record.frontmatter["affected-components"]).map(&:to_s)
|
|
reasons = []
|
|
reasons << "path" if overlap?(affected_paths, paths)
|
|
reasons << "graph" if reasons.empty? && overlap?(affected_paths, graph_paths)
|
|
reasons << "component" if (affected_components & components).any?
|
|
reasons
|
|
end
|
|
|
|
def overlap?(affected, queried)
|
|
affected.any? { |a| queried.any? { |q| path_match?(a, q) } }
|
|
end
|
|
|
|
# Prefix containment either way: an ADR affecting `lib/storage` matches a
|
|
# query for `lib/storage/adapter.rb`, and one affecting a specific file
|
|
# matches a query for its directory.
|
|
def path_match?(a, q)
|
|
a == q || q.start_with?("#{a}/") || a.start_with?("#{q}/")
|
|
end
|
|
|
|
def normalize(paths)
|
|
paths.map { |p| p.to_s.chomp("/") }.reject(&:empty?).uniq
|
|
end
|
|
|
|
# Files one graph hop from the queried files, via graphify-out/graph.json
|
|
# node source_file attributes. Any failure degrades to no expansion.
|
|
def graph_neighbors(paths)
|
|
return [] unless @graph_path && File.exist?(@graph_path)
|
|
|
|
graph = JSON.parse(File.read(@graph_path))
|
|
nodes = graph.fetch("nodes", [])
|
|
id_to_file = nodes.to_h { |n| [n["id"], n["source_file"]] }
|
|
seeds = nodes.select do |n|
|
|
n["source_file"] && paths.any? { |q| path_match?(n["source_file"].chomp("/"), q) }
|
|
end.map { |n| n["id"] }.to_set
|
|
return [] if seeds.empty?
|
|
|
|
graph.fetch("links", []).flat_map do |link|
|
|
if seeds.include?(link["source"]) then [link["target"]]
|
|
elsif seeds.include?(link["target"]) then [link["source"]]
|
|
else []
|
|
end
|
|
end.filter_map { |id| id_to_file[id] }.uniq
|
|
rescue StandardError
|
|
[]
|
|
end
|
|
end
|
|
end
|