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

227 lines
7.8 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.

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