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
|