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