#!/usr/bin/env ruby # frozen_string_literal: true # Deterministic per-scenario checker for the os-adr model-tier eval (Eval A). # # check [--tsv ] # # Reads the sandbox produced by bin/sandbox after a model run. Scenarios S3/S4 # additionally require the model's final answer saved to /ANSWER.md. # Human mode prints PASS/FAIL + reasons; --tsv prints one tab-separated line # (scenario, model, PASS|FAIL, reasons) for autoresearch's *-results.tsv. # Exit 0 on PASS, 1 on FAIL, 2 on usage error. require "date" module AdrEval PENDING = /_pending LLM fill/i # Minimal frontmatter reader — deliberately independent of lib/adr so the # checker cannot inherit a bug from the code it is scoring around. class Frontmatter def self.parse(text) block = text[/\A---\n(.*?)\n---\n/m, 1] or return nil fields = {} block.scan(/^([\w-]+):[ \t]*(.*)$/) { |k, v| fields[k] = v.strip.gsub(/\A"|"\z/, "") } new(fields) end def initialize(fields) = @fields = fields def [](key) = @fields[key] def list(key) = (@fields[key] || "").scan(/"([^"]*)"|([^\[\],\s]+)/).flatten.compact end class Sandbox def initialize(root, eval_root) @root = root @eval_root = eval_root end attr_reader :root def read(rel) = File.read(File.join(@root, rel)) def exist?(rel) = File.exist?(File.join(@root, rel)) def adr_files = Dir.glob(File.join(@root, "docs/adr/[0-9]*.md")).sort def answer = exist?("ANSWER.md") ? read("ANSWER.md") : nil def unchanged_from_fixture?(rel, fixture) original = File.join(@eval_root, "fixture", fixture, rel) File.exist?(original) && exist?(rel) && File.read(original) == read(rel) end def index = exist?("docs/adr/README.md") ? read("docs/adr/README.md") : "" end class Result def initialize = @failures = [] def pass? = @failures.empty? attr_reader :failures # Returns the condition so callers can bail early on failure. def expect(condition, reason) @failures << reason unless condition !!condition end end class Scenario def initialize(sandbox) = @sb = sandbox def run result = Result.new check(result) result end private attr_reader :sb # A well-formed, fully-authored ADR file at the given path. def expect_complete_adr(result, path, id:) unless sb.exist?(path) result.expect(false, "missing #{path}") return nil end text = sb.read(path) fm = Frontmatter.parse(text) return nil unless result.expect(!fm.nil?, "#{path}: no frontmatter") result.expect(fm["id"] == id, "#{path}: id #{fm['id'].inspect} != #{id.inspect}") result.expect(text !~ AdrEval::PENDING, "#{path}: pending-fill markers remain") %w[Context Decision Consequences].each do |section| body = text[/^## #{section}\n(.*?)(?=^## |\z)/m, 1].to_s.strip result.expect(!body.empty?, "#{path}: empty #{section} section") end fm end def expect_indexed(result, id) result.expect(sb.index.include?(id), "index README.md has no row for #{id}") end def expect_fixture_adrs_untouched(result, except: []) %w[0001 0002 0003 0004].each do |id| next if except.include?(id) rel = Dir.glob(File.join(sb.root, "docs/adr/#{id}-*.md")).first next result.expect(false, "fixture ADR #{id} missing") unless rel rel = rel.sub("#{sb.root}/", "") result.expect(sb.unchanged_from_fixture?(rel, "project"), "fixture ADR #{id} was modified") end end end class S1 < Scenario # create: new decision recorded via CLI def check(result) fm = expect_complete_adr(result, new_adr_path, id: "0005") return unless fm result.expect(fm["status"] == "Accepted", "0005 status #{fm['status'].inspect} != Accepted") result.expect(!fm.list("affected-paths").empty?, "0005 affected-paths empty") expect_indexed(result, "0005") expect_fixture_adrs_untouched(result) end def new_adr_path path = Dir.glob(File.join(sb.root, "docs/adr/0005-*.md")).first path ? path.sub("#{sb.root}/", "") : "docs/adr/0005-*.md" end end class S2 < S1 # create with supersession of 0001 def check(result) fm = expect_complete_adr(result, new_adr_path, id: "0005") return unless fm result.expect(fm["supersedes"] == "0001", "0005 supersedes #{fm['supersedes'].inspect} != 0001") old = Frontmatter.parse(sb.read(old_adr_rel)) result.expect(old["status"] == "Superseded", "0001 status #{old['status'].inspect} != Superseded") result.expect(old["superseded-by"] == "0005", "0001 superseded-by #{old['superseded-by'].inspect} != 0005") expect_indexed(result, "0005") expect_fixture_adrs_untouched(result, except: %w[0001]) end def old_adr_rel Dir.glob(File.join(sb.root, "docs/adr/0001-*.md")).first.sub("#{sb.root}/", "") end end class FindScenario < Scenario def check(result) answer = sb.answer return unless result.expect(!answer.nil?, "no ANSWER.md saved in sandbox") result.expect(answer.include?("adr-find"), "answer never mentions running adr-find") check_answer(result, answer) expect_fixture_adrs_untouched(result) end end class S3 < FindScenario # find: direct path match + conflict recognition def check_answer(result, answer) result.expect(answer.include?("0001"), "answer does not surface ADR 0001") result.expect(answer.match?(/conflict|violat|supersed|contradic|locked/i), "answer does not flag the conflict with 0001") end end class S4 < FindScenario # find: distractor discrimination (0003 not superseded 0002) def check_answer(result, answer) result.expect(answer.include?("0003"), "answer does not surface ADR 0003") if answer.include?("0002") result.expect(answer.match?(/supersed/i), "answer cites superseded 0002 without noting it is superseded") end end end class S5 < Scenario # init on the legacy project def check(result) result.expect(sb.exist?("docs/adr/README.md"), "docs/adr/README.md not created") result.expect(sb.index.include?("