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