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

227 lines
7.8 KiB
Ruby
Raw Normal View History

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