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

109 lines
3.7 KiB
Ruby
Raw Normal View History

module Adr
# Mechanical classification of existing ADR-like content into the surveyed
# shapes, enumerating migration units. Report-only for unrecognized content —
# nothing here writes anything.
class Detector
Unit = Struct.new(:shape, :source_path, :anchor, :title, :body, keyword_init: true) do
def source_ref
anchor ? "#{source_path}##{anchor}" : source_path
end
end
CANDIDATE_DIRS = [%w[docs adr], %w[docs decisions], %w[docs decision]].freeze
GENERATED_BASENAMES = %w[README.md migration-report.md].freeze
UNIT_HEADING = /^\#{1,4}\s*(?:ADR[-\s]?\d+|D-\d+)\b/i
PROSE_DECISION = /^(?:\#{1,4}\s*|\*\*)?Decision\s+\d+\s*[:—–-]/i
DECISION_MARKERS = /^(?:\#{1,4}\s*|\*\*)?(?:Status|Decision|Decided|Context)\b/i
def initialize(root:)
@root = root
end
def units
candidate_files.flat_map { |path| classify_file(path) }
end
def candidate_files
files = CANDIDATE_DIRS.flat_map do |parts|
Dir.glob(File.join(@root, *parts, "*.md"))
end
files += Dir.glob(File.join(@root, "DECISIONS.md"))
files.uniq.sort.reject { |p| generated?(p) || already_migrated?(p) }
end
private
def generated?(path)
GENERATED_BASENAMES.include?(File.basename(path))
end
# A file already in the os-adr format (frontmatter with an id) is not a
# migration source.
def already_migrated?(path)
text = File.read(path)
return false unless text.start_with?("---\n")
!Record.parse(text).id.nil?
rescue Record::ParseError, StandardError
false
end
def classify_file(path)
text = File.read(path)
base = File.basename(path)
relative = relative(path)
if text.scan(UNIT_HEADING).size >= 2
split_units(relative, text, :monolithic, UNIT_HEADING)
elsif text.scan(PROSE_DECISION).size >= 2
split_units(relative, text, :prose_embedded, PROSE_DECISION)
elsif text.scan(/^\**Status\**\s*[:]/i).size >= 2
# Multiple decisions but no mechanically splittable unit headings:
# report-only, never auto-converted.
[Unit.new(shape: :unrecognized, source_path: relative, anchor: nil,
title: base, body: text)]
elsif text.match?(DECISION_MARKERS)
shape =
if base.match?(/\A\d{4}-\d{2}(-\d{2})?-/) then :dated_single_file
elsif base.match?(/\A\d+-/) then :numbered_per_file
else :topic_single_file
end
[Unit.new(shape: shape, source_path: relative, anchor: nil,
title: single_title(text, base), body: text)]
else
[Unit.new(shape: :unrecognized, source_path: relative, anchor: nil,
title: base, body: text)]
end
end
def split_units(relative, text, shape, pattern)
lines = text.lines
starts = lines.each_index.select { |i| lines[i].match?(pattern) }
starts.each_with_index.map do |start, n|
stop = starts[n + 1] || lines.size
heading = lines[start].strip
Unit.new(shape: shape, source_path: relative, anchor: heading,
title: unit_title(heading), body: lines[start...stop].join)
end
end
def unit_title(heading)
heading
.sub(/\A\#{1,4}\s*/, "")
.gsub("**", "")
.sub(/\A(?:ADR[-\s]?\d+|D-\d+|Decision\s+\d+)\s*[:—–-]?\s*/i, "")
.strip
end
def single_title(text, base)
h1 = text[/^# +(.+)$/, 1]
return h1.strip.sub(/\A(?:ADR[-\s]?\d+|D-\d+|\d{1,4})\s*[:—–-]\s*/i, "") if h1
base.sub(/\.md\z/, "").sub(/\A[\d-]+/, "").tr("-", " ").strip.capitalize
end
def relative(path)
path.sub(%r{\A#{Regexp.escape(@root)}/?}, "")
end
end
end