require_relative "test_helper" require "digest" class MigratorTest < Minitest::Test include AdrTestHelpers FULL_SOURCE = <<~MD # 0001 — PocketBase backend ## Status Accepted ## Context Need a backend. ## Decision PocketBase. ## Consequences Single binary. ## Alternatives considered Rails — too heavy. MD PARTIAL_SOURCE = <<~MD # bin/lint-fix Status: Confirmed Decided: 2026-02-01 ## Context Lint config drifted per repo. ## Decision One lint entry point. ## Why Consistency across repos. MD BARE_SOURCE = <<~MD # Something happened Status: Accepted We did a thing, narratively. MD def project(sources) Dir.mktmpdir do |root| decisions = File.join(root, "docs", "decisions") FileUtils.mkdir_p(decisions) sources.each { |name, body| File.write(File.join(decisions, name), body) } repo = Adr::Repository.new(root: root) migrator = Adr::Migrator.new(repository: repo, detector: Adr::Detector.new(root: root)) yield root, repo, migrator end end def test_sources_stay_byte_identical project("0001-full.md" => FULL_SOURCE, "partial.md" => PARTIAL_SOURCE) do |root, _repo, migrator| before = Dir.glob(File.join(root, "docs", "decisions", "*")) .to_h { |p| [p, Digest::SHA256.file(p).hexdigest] } migrator.migrate! before.each { |path, sha| assert_equal sha, Digest::SHA256.file(path).hexdigest } end end def test_unambiguous_fields_fill_mechanically_high_confidence project("0001-full.md" => FULL_SOURCE) do |_root, repo, migrator| manifest = migrator.migrate! entry = repo.entries.first record = entry.record assert_equal "Accepted", record.status assert_equal "PocketBase backend", record.title assert_equal "Need a backend.", record.sections["Context"] assert_equal "Rails — too heavy.", record.sections["Alternatives rejected"] assert_equal "high", record.frontmatter["migration_confidence"] assert_equal "docs/decisions/0001-full.md", record.frontmatter["migration_source"] assert_empty manifest["migrated"].first["llm_fields"] end end def test_interpretive_fields_get_pending_markers_and_manifest project("partial.md" => PARTIAL_SOURCE) do |_root, repo, migrator| manifest = migrator.migrate! record = repo.entries.first.record assert_equal "Accepted", record.status, "Confirmed maps mechanically to Accepted" assert_equal Date.new(2026, 2, 1), record.date assert_equal Adr::Migrator::PENDING_MARKER, record.sections["Consequences"] assert_equal Adr::Migrator::PENDING_MARKER, record.sections["Alternatives rejected"] assert_equal "medium", record.frontmatter["migration_confidence"] entry = manifest["migrated"].first assert_equal %w[consequences alternatives], entry["llm_fields"] assert_includes entry["source_material"], "Consistency across repos." end end def test_fallback_heading_mapping_caps_confidence_at_medium source = <<~MD # Topic Status: Accepted ## Decision Do the thing. ## Why Because reasons. ## Consequences Some. ## Alternatives rejected None seriously. MD project("topic.md" => source) do |_root, repo, migrator| migrator.migrate! record = repo.entries.first.record assert_equal "Because reasons.", record.sections["Context"], "Why maps to Context" assert_equal "medium", record.frontmatter["migration_confidence"], "heuristic heading mapping is flagged" end end def test_missing_core_fields_get_not_stated_markers_and_low_confidence project("bare.md" => BARE_SOURCE) do |_root, repo, migrator| migrator.migrate! record = repo.entries.first.record assert_equal Adr::Migrator::NOT_STATED_MARKER, record.sections["Decision"] assert_equal Adr::Migrator::NOT_STATED_MARKER, record.sections["Context"] assert_equal "low", record.frontmatter["migration_confidence"] end end def test_apply_fills_only_touches_pending_fields project("partial.md" => PARTIAL_SOURCE, "bare.md" => BARE_SOURCE) do |root, repo, migrator| manifest = migrator.migrate! pending_file = manifest["migrated"].find { |m| m["source"].include?("partial") }["file"] bare_file = manifest["migrated"].find { |m| m["source"].include?("bare") }["file"] migrator.apply_fills!( pending_file => { "consequences" => "Fewer lint debates.", "alternatives" => "Per-repo configs — rejected, drift." }, bare_file => { "decision" => "INVENTED", "context" => "INVENTED" } ) filled = Adr::Record.parse(File.read(File.join(root, pending_file))) assert_equal "Fewer lint debates.", filled.sections["Consequences"] assert_equal "medium", filled.frontmatter["migration_confidence"], "LLM-filled stays flagged" bare = Adr::Record.parse(File.read(File.join(root, bare_file))) assert_equal Adr::Migrator::NOT_STATED_MARKER, bare.sections["Decision"], "core fields are never LLM-inventable" assert_equal "low", bare.frontmatter["migration_confidence"] end end def test_report_lists_files_confidence_sources_and_flag_rate project("0001-full.md" => FULL_SOURCE, "bare.md" => BARE_SOURCE) do |root, _repo, migrator| manifest = migrator.migrate! report = File.read(File.join(root, "docs", "adr", "migration-report.md")) assert_includes report, "docs/decisions/0001-full.md" assert_includes report, "| low |" assert_includes report, "| high |" assert_includes report, "flag rate: 50.0%" assert_equal 0.5, manifest["flag_rate"] end end def test_unrecognized_content_is_reported_never_converted project("notes.md" => "# Notes\n\nchatter\n") do |root, repo, migrator| manifest = migrator.migrate! assert_empty repo.entries assert_equal "docs/decisions/notes.md", manifest["unrecognized"].first["source"] assert_includes File.read(File.join(root, "docs", "adr", "migration-report.md")), "docs/decisions/notes.md" end end def test_migration_is_idempotent_across_reruns project("0001-full.md" => FULL_SOURCE) do |_root, repo, migrator| migrator.migrate! migrator.migrate! assert_equal 1, repo.entries.size end end def test_index_is_regenerated_with_migrated_adrs project("0001-full.md" => FULL_SOURCE) do |_root, repo, migrator| migrator.migrate! assert_includes File.read(repo.index_path), "PocketBase backend" end end end