cc-os/plugins/os-adr/lib/adr/finder.rb

84 lines
3.1 KiB
Ruby
Raw Normal View History

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