227 lines
7.8 KiB
Ruby
227 lines
7.8 KiB
Ruby
|
|
require "fileutils"
|
|||
|
|
|
|||
|
|
module Adr
|
|||
|
|
# Mechanical migration pass. Converts detected units into new-format ADRs
|
|||
|
|
# under docs/adr/ — heuristic-filling only structurally unambiguous fields —
|
|||
|
|
# and emits a manifest of the interpretive fields an LLM must fill. Writes
|
|||
|
|
# nothing outside docs/adr/; never touches source files.
|
|||
|
|
class Migrator
|
|||
|
|
PENDING_MARKER = "_pending LLM fill (see migration manifest)_"
|
|||
|
|
NOT_STATED_MARKER = "_not stated in source_"
|
|||
|
|
|
|||
|
|
# Mechanical status normalization: canonical lifecycle values plus the one
|
|||
|
|
# unambiguous client-facing synonym. Anything else is copied raw.
|
|||
|
|
STATUS_MAP = {
|
|||
|
|
"accepted" => "Accepted", "proposed" => "Proposed",
|
|||
|
|
"superseded" => "Superseded", "deprecated" => "Deprecated",
|
|||
|
|
"confirmed" => "Accepted", "settled" => "Accepted", "open" => "Proposed"
|
|||
|
|
}.freeze
|
|||
|
|
|
|||
|
|
# Source heading -> target section. Interpretive targets (consequences,
|
|||
|
|
# alternatives) get PENDING when absent; core targets (context, decision)
|
|||
|
|
# get NOT_STATED — they are never invented, by an LLM or otherwise.
|
|||
|
|
SECTION_SOURCES = {
|
|||
|
|
"Context" => [/\Acontext\z/i],
|
|||
|
|
"Decision" => [/\Adecision\z/i],
|
|||
|
|
"Consequences" => [/\Aconsequences\z/i],
|
|||
|
|
"Alternatives rejected" => [/\Aalternatives( (rejected|considered))?\z/i]
|
|||
|
|
}.freeze
|
|||
|
|
|
|||
|
|
# Heading synonyms mapped as a heuristic: the content is copied verbatim
|
|||
|
|
# from the source, but because the mapping is a judgment call the file's
|
|||
|
|
# confidence is capped at medium.
|
|||
|
|
SECTION_FALLBACKS = {
|
|||
|
|
"Context" => [/\A(why|rationale|problem|background)\z/i],
|
|||
|
|
"Consequences" => [/\A(impact|implications|trade-?offs)\z/i]
|
|||
|
|
}.freeze
|
|||
|
|
CORE_SECTIONS = ["Context", "Decision"].freeze
|
|||
|
|
FILL_FIELDS = { "consequences" => "Consequences",
|
|||
|
|
"alternatives" => "Alternatives rejected" }.freeze
|
|||
|
|
|
|||
|
|
def initialize(repository:, detector:)
|
|||
|
|
@repository = repository
|
|||
|
|
@detector = detector
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def migrate!
|
|||
|
|
FileUtils.mkdir_p(@repository.dir)
|
|||
|
|
recognized, unrecognized = @detector.units.partition { |u| u.shape != :unrecognized }
|
|||
|
|
fresh = recognized.reject { |u| migrated_sources.include?(u.source_ref) }
|
|||
|
|
results = fresh.map { |unit| convert(unit) }
|
|||
|
|
finalize!
|
|||
|
|
manifest(results, unrecognized)
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
# fills: {"docs/adr/NNNN-x.md" => {"consequences" => "...", "status" => "..."}}
|
|||
|
|
# Only pending-marker sections and an empty status are fillable — the
|
|||
|
|
# manifest is enforced structurally, not trusted from the caller.
|
|||
|
|
def apply_fills!(fills)
|
|||
|
|
fills.each do |relpath, fields|
|
|||
|
|
path = File.join(@repository.root, relpath)
|
|||
|
|
record = Record.parse(File.read(path))
|
|||
|
|
fields.each { |field, value| apply_fill(record, field, value) }
|
|||
|
|
File.write(path, record.to_markdown)
|
|||
|
|
end
|
|||
|
|
finalize!
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
private
|
|||
|
|
|
|||
|
|
def migrated_sources
|
|||
|
|
@repository.entries.filter_map { |e| e.record.frontmatter["migration_source"] }
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def convert(unit)
|
|||
|
|
extraction = Extraction.new(unit)
|
|||
|
|
sections, llm_fields, fallback_used = build_sections(extraction)
|
|||
|
|
confidence = confidence_for(sections, llm_fields, extraction, fallback_used)
|
|||
|
|
llm_fields << "status" if extraction.status.to_s.empty?
|
|||
|
|
|
|||
|
|
record = Record.new(
|
|||
|
|
frontmatter: {
|
|||
|
|
"id" => @repository.next_id,
|
|||
|
|
"date" => extraction.date,
|
|||
|
|
"status" => extraction.status,
|
|||
|
|
"supersedes" => nil, "superseded-by" => nil,
|
|||
|
|
"affected-paths" => [], "affected-components" => [],
|
|||
|
|
"migration_confidence" => confidence,
|
|||
|
|
"migration_source" => unit.source_ref
|
|||
|
|
},
|
|||
|
|
title: unit.title,
|
|||
|
|
sections: sections
|
|||
|
|
)
|
|||
|
|
path = @repository.write(record)
|
|||
|
|
{ "file" => relative(path), "source" => unit.source_ref, "shape" => unit.shape.to_s,
|
|||
|
|
"confidence" => confidence, "llm_fields" => llm_fields,
|
|||
|
|
"source_material" => llm_fields.empty? ? nil : unit.body }.compact
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def build_sections(extraction)
|
|||
|
|
llm_fields = []
|
|||
|
|
sections = {}
|
|||
|
|
fallback_used = false
|
|||
|
|
Record::SECTION_ORDER.each do |name|
|
|||
|
|
found = extraction.section(name)
|
|||
|
|
if found.nil? && (fallback = extraction.fallback_section(name))
|
|||
|
|
found = fallback
|
|||
|
|
fallback_used = true
|
|||
|
|
end
|
|||
|
|
sections[name] =
|
|||
|
|
if found
|
|||
|
|
found
|
|||
|
|
elsif CORE_SECTIONS.include?(name)
|
|||
|
|
NOT_STATED_MARKER
|
|||
|
|
else
|
|||
|
|
llm_fields << FILL_FIELDS.key(name)
|
|||
|
|
PENDING_MARKER
|
|||
|
|
end
|
|||
|
|
end
|
|||
|
|
[sections, llm_fields, fallback_used]
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def confidence_for(sections, llm_fields, extraction, fallback_used)
|
|||
|
|
return "low" if CORE_SECTIONS.any? { |name| sections[name] == NOT_STATED_MARKER }
|
|||
|
|
return "medium" if llm_fields.any? || fallback_used || extraction.status.to_s.empty?
|
|||
|
|
|
|||
|
|
"high"
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def apply_fill(record, field, value)
|
|||
|
|
if field == "status"
|
|||
|
|
record.frontmatter["status"] = value if record.status.to_s.empty?
|
|||
|
|
elsif (section = FILL_FIELDS[field])
|
|||
|
|
record.sections[section] = value if record.sections[section] == PENDING_MARKER
|
|||
|
|
end
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def finalize!
|
|||
|
|
Index.new(repository: @repository).regenerate!
|
|||
|
|
MigrationReport.new(repository: @repository, detector: @detector).write!
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def manifest(results, unrecognized)
|
|||
|
|
{
|
|||
|
|
"migrated" => results,
|
|||
|
|
"unrecognized" => unrecognized.map { |u| { "source" => u.source_ref, "title" => u.title } },
|
|||
|
|
"flag_rate" => MigrationReport.new(repository: @repository, detector: @detector).flag_rate
|
|||
|
|
}
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def relative(path)
|
|||
|
|
path.sub(%r{\A#{Regexp.escape(@repository.root)}/?}, "")
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
# Mechanical field extraction from one unit's text: only structurally
|
|||
|
|
# unambiguous values (labelled lines, exact headings, ISO dates).
|
|||
|
|
class Extraction
|
|||
|
|
def initialize(unit)
|
|||
|
|
@unit = unit
|
|||
|
|
@body = unit.body
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def status
|
|||
|
|
raw = labelled_value("Status") || heading_section("Status")&.lines&.first&.strip
|
|||
|
|
return nil if raw.nil? || raw.empty?
|
|||
|
|
|
|||
|
|
STATUS_MAP.fetch(raw.split(/[\s(]/).first.downcase, raw)
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def date
|
|||
|
|
raw = labelled_value("Date") || labelled_value("Decided")
|
|||
|
|
iso = raw&.[](/\d{4}-\d{2}-\d{2}/) ||
|
|||
|
|
@unit.source_path[/\d{4}-\d{2}-\d{2}/]
|
|||
|
|
iso && Date.parse(iso)
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def section(target)
|
|||
|
|
match_section(SECTION_SOURCES.fetch(target))
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def fallback_section(target)
|
|||
|
|
match_section(SECTION_FALLBACKS.fetch(target, []))
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
private
|
|||
|
|
|
|||
|
|
def match_section(patterns)
|
|||
|
|
patterns.each do |pattern|
|
|||
|
|
headings.each do |name, content|
|
|||
|
|
return content if name.match?(pattern) && !content.empty?
|
|||
|
|
end
|
|||
|
|
end
|
|||
|
|
nil
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def labelled_value(label)
|
|||
|
|
@body[/^[-*]?\s*\**#{label}\**\s*[::]\s*\**([^\n*]+)/i, 1]&.strip
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def heading_section(name)
|
|||
|
|
headings.find { |heading, _| heading.match?(/\A#{name}\z/i) }&.last
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def headings
|
|||
|
|
@headings ||= heading_sections + bold_label_sections
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def heading_sections
|
|||
|
|
@body.scan(/^\#{2,4} +([^\n]+)\n(.*?)(?=^\#{2,4} |\z)/m)
|
|||
|
|
.map { |name, content| [name.strip, strip_trailing_rule(content)] }
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
# "**Context.** text..." paragraphs (bold-label style, e.g. llf-schema):
|
|||
|
|
# the label acts as a section heading; content runs to the next bold
|
|||
|
|
# label, heading, or end of unit.
|
|||
|
|
def bold_label_sections
|
|||
|
|
@body.scan(/^\*\*([A-Za-z][^*\n]{0,40}?)[.:]?\*\*[.:]?\s*(.*?)(?=^\*\*[A-Za-z]|^\#{1,4} |\z)/m)
|
|||
|
|
.map { |name, content| [name.strip, strip_trailing_rule(content)] }
|
|||
|
|
.reject { |_, content| content.empty? }
|
|||
|
|
end
|
|||
|
|
|
|||
|
|
def strip_trailing_rule(content)
|
|||
|
|
content.strip.sub(/\n+---+\s*\z/, "").strip
|
|||
|
|
end
|
|||
|
|
end
|
|||
|
|
end
|
|||
|
|
end
|