109 lines
3.7 KiB
Ruby
109 lines
3.7 KiB
Ruby
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
|