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

109 lines
3.7 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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