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