Login
gpcr/spec/self_conformance_spec.cr
Login

[edit]

spec/self_conformance_spec.cr @ trunk

spec

require 'spec'
scale = 0.75
fontscale = 1.500
box wid 1.380 ht 0.340 with .nw at (0.035, -0.035) fill 0xCBD5E1 color 0xCBD5E1
box wid 1.380 ht 0.340 with .nw at (0.000, 0.000) fill 0x7C83E6 color 0x7C83E6
box wid 0.300 ht 0.065 rad 0.018 at (0.370, -0.022) fill 0xffffff color 0xffffff
line from (0.140, 0.000) to (0.140, -0.340) color white
line from (1.240, 0.000) to (1.240, -0.340) color white
text "require 'spec'" color white at (0.690, -0.170)

crystal source

require "spec"

json block

{
  "kind": "require",
  "name": "spec",
  "doc": null,
  "children": []
}

json

require 'json'
scale = 0.75
fontscale = 1.500
box wid 1.330 ht 0.340 with .nw at (0.035, -0.455) fill 0xCBD5E1 color 0xCBD5E1
box wid 1.330 ht 0.340 with .nw at (0.000, -0.420) fill 0x7C83E6 color 0x7C83E6
box wid 0.300 ht 0.065 rad 0.018 at (0.370, -0.443) fill 0xffffff color 0xffffff
line from (0.140, -0.420) to (0.140, -0.760) color white
line from (1.190, -0.420) to (1.190, -0.760) color white
text "require 'json'" color white at (0.665, -0.590)

crystal source

require "json"

json block

{
  "kind": "require",
  "name": "json",
  "doc": null,
  "children": []
}

compiler/crystal/syntax

require 'compiler/crystal/syntax'
scale = 0.75
fontscale = 1.500
box wid 2.796 ht 0.340 with .nw at (0.035, -0.875) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.796 ht 0.340 with .nw at (0.000, -0.840) fill 0x7C83E6 color 0x7C83E6
box wid 0.300 ht 0.065 rad 0.018 at (0.370, -0.863) fill 0xffffff color 0xffffff
line from (0.140, -0.840) to (0.140, -1.180) color white
line from (2.656, -0.840) to (2.656, -1.180) color white
text "require 'compiler/crystal/syntax'" color white at (1.398, -1.010)

crystal source

require "compiler/crystal/syntax"

json block

{
  "kind": "require",
  "name": "compiler/crystal/syntax",
  "doc": null,
  "children": []
}

../src/gpcr/render

require '../src/gpcr/render'
scale = 0.75
fontscale = 1.500
box wid 2.343 ht 0.340 with .nw at (0.035, -1.295) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.343 ht 0.340 with .nw at (0.000, -1.260) fill 0x7C83E6 color 0x7C83E6
box wid 0.300 ht 0.065 rad 0.018 at (0.370, -1.283) fill 0xffffff color 0xffffff
line from (0.140, -1.260) to (0.140, -1.600) color white
line from (2.203, -1.260) to (2.203, -1.600) color white
text "require '../src/gpcr/render'" color white at (1.172, -1.430)

crystal source

require "../src/gpcr/render"

json block

{
  "kind": "require",
  "name": "../src/gpcr/render",
  "doc": null,
  "children": []
}

../src/gpcr/apply

require '../src/gpcr/apply'
scale = 0.75
fontscale = 1.500
box wid 2.255 ht 0.340 with .nw at (0.035, -1.715) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.255 ht 0.340 with .nw at (0.000, -1.680) fill 0x7C83E6 color 0x7C83E6
box wid 0.300 ht 0.065 rad 0.018 at (0.370, -1.703) fill 0xffffff color 0xffffff
line from (0.140, -1.680) to (0.140, -2.020) color white
line from (2.115, -1.680) to (2.115, -2.020) color white
text "require '../src/gpcr/apply'" color white at (1.127, -1.850)

crystal source

require "../src/gpcr/apply"

json block

{
  "kind": "require",
  "name": "../src/gpcr/apply",
  "doc": null,
  "children": []
}

../src/gpcr/census

require '../src/gpcr/census'
scale = 0.75
fontscale = 1.500
box wid 2.393 ht 0.340 with .nw at (0.035, -2.135) fill 0xCBD5E1 color 0xCBD5E1
box wid 2.393 ht 0.340 with .nw at (0.000, -2.100) fill 0x7C83E6 color 0x7C83E6
box wid 0.300 ht 0.065 rad 0.018 at (0.370, -2.123) fill 0xffffff color 0xffffff
line from (0.140, -2.100) to (0.140, -2.440) color white
line from (2.253, -2.100) to (2.253, -2.440) color white
text "require '../src/gpcr/census'" color white at (1.196, -2.270)

crystal source

require "../src/gpcr/census"

json block

{
  "kind": "require",
  "name": "../src/gpcr/census",
  "doc": null,
  "children": []
}

PRODUCIBILITY_GAP_KINDS = [:c_call, :if, :while, :case, :when, :other]

# GPCR self-conformance spec.
#
# (a) Contract sweep: compiles a temp fixture requiring every src/gpcr/*.cr
#     and calls GPCR.check_blocks, so any future file that omits its trailing
#     check is still swept here.
#
# (b) Self-render: runs GPCR::Renderer over every src/gpcr/*.cr and asserts:
#     - all backtick fences in the output are balanced
#     - each file that declares @[GPCR::Block] defs has at least one section
#       where the pikchr fence renders a body stack (face + children)
#
# (c) Pikchr syntax: each pikchr fence is validated via `fossil pikchr`;
#     the whole group is pending-with-reason when the fossil binary is absent.
#
# (d) OP-PRODUCIBILITY: every @[GPCR::Graph] and @[GPCR::Block] def is checked
#     for op-producibility. Flat :call bodies are round-tripped through Buffer.
#     Non-producible node kinds are reported as an OP-COVERAGE GAP table.
#
# (e) CENSUS RATCHET: project-wide GPCR line percentage and per-file GPCR
#     line counts must never decrease vs the committed baseline in
#     spec/fixtures/census_baseline.json.

# Track fence state to determine if all backtick fences are balanced.
private def fences_balanced?(md : String) : Bool
  open = false
  marker = ""
  md.each_line do |line|
    s = line.rstrip
    if !open
      if m = s.match(/^(`{3,})/)
        marker = m[1]
        open = true
      end
    elsif s == marker
      open = false
      marker = ""
    end
  end
  !open
end

# Extract pikchr fence bodies using stateful fence tracking.
# Uses chomp: false so each appended line retains its trailing newline,
# producing valid multiline pikchr content. Handles variable-length fence
# markers (3, 4, ... backticks), so backtick strings inside 4-backtick crystal
# fences are not confused with pikchr fence openers.
private def extract_pikchr_fences(md : String) : Array(String)
  result = [] of String
  in_fence = false
  fence_marker = ""
  is_pikchr = false
  body = [] of String

  md.each_line(chomp: false) do |line|
    s = line.rstrip
    if !in_fence
      if m = s.match(/^(`{3,})/)
        fence_marker = m[1]
        is_pikchr = s.starts_with?(fence_marker + "pikchr")
        in_fence = true
      end
    elsif s == fence_marker
      result << body.join if is_pikchr
      body.clear
      in_fence = false
      is_pikchr = false
    elsif is_pikchr
      body << line
    end
  end
  result
end

# Check whether a section of rendered markdown for node_name has a body stack.
# A body stack appears below the face: face-only sections are ≤5 pikchr lines
# (scale + 2-4 face elements). Any body element adds ≥4 lines (command block:
# box + 2 tabs + text), so > 6 lines reliably signals a body stack.
private def section_has_body_stack?(md : String, node_name : String) : Bool
  parts = md.split("## #{node_name}")
  return false if parts.size < 2
  pikchr = extract_pikchr_fences(parts[1]).first?
  return false unless pikchr
  pikchr.lines.size > 6
end

# ── Producibility helpers ─────────────────────────────────────────────────────

# Recursively collect all GPCR::Node kinds found in a body node list.
private def collect_body_kinds(nodes : Array(GPCR::Node)) : Array(Symbol)
  kinds = [] of Symbol
  nodes.each do |n|
    kinds << n.kind
    kinds.concat(collect_body_kinds(n.children))
    kinds.concat(collect_body_kinds(n.else_nodes))
  end
  kinds.uniq
end

# Structural equality of two GPCR::Node arrays (kind/name/args/children, not line numbers).
private def nodes_struct_equal?(a : Array(GPCR::Node), b : Array(GPCR::Node)) : Bool
  return false unless a.size == b.size
  a.zip(b).all? do |(na, nb)|
    na.kind == nb.kind &&
      na.name == nb.name &&
      na.args == nb.args &&
      nodes_struct_equal?(na.children, nb.children) &&
      nodes_struct_equal?(na.else_nodes, nb.else_nodes)
  end
end

# Attempt a round-trip for a def whose body contains only flat :call nodes.
# Builds a skeleton (original source with body = first call only), then applies
# AddOp for each subsequent call, and compares the resulting Node tree.
# Returns {passed, reason}.
private def attempt_round_trip(source : String, def_name : String, orig_children : Array(GPCR::Node)) : {Bool, String}
  return {true, "empty body"} if orig_children.empty?

  index_nodes, params = GPCR::Ops::NodeIndexer.new(source).index

  def_ln = index_nodes.find { |n| n.kind == :def && n.name == def_name }
  return {false, "def not in NodeIndex"} unless def_ln

  def_idx = def_ln.not_nil!.index
  next_def = index_nodes.find { |n| n.kind == :def && n.index > def_idx }
  upper = next_def.try(&.index) || (index_nodes.size + 1)

  body_calls = index_nodes.select { |n| n.index > def_idx && n.index < upper && n.kind == :call }

  if orig_children.size <= 1
    # Trivial: single call or no calls — skeleton IS the original source.
    # Just verify parse_source produces the same tree.
    prims = {} of String => GPCR::Prim
    result_nodes = GPCR.parse_source(source, prims)
    result_def = result_nodes.find { |n| n.name == def_name }
    return {false, "def not in parse result"} unless result_def
    equal = nodes_struct_equal?(result_def.not_nil!.children, orig_children)
    return {equal, "trivial-single (#{orig_children.size} call)"}
  end

  # Multi-call: build skeleton = source with body reduced to first call only.
  lines = source.lines(chomp: false)
  # Guard: NodeIndexer only recurses into @[GPCR::Graph] bodies, so @[GPCR::Block]
  # defs with multiple children have no indexed body calls. Fall back to parse-tree
  # verification only — AddOp targeting is unsupported for non-graph def bodies.
  if body_calls.empty?
    prims = {} of String => GPCR::Prim
    result_nodes = GPCR.parse_source(source, prims)
    result_def = result_nodes.find { |n| n.name == def_name }
    return {false, "def not in parse result"} unless result_def
    equal = nodes_struct_equal?(result_def.not_nil!.children, orig_children)
    return {equal, "block def body not indexed by NodeIndexer; parse-tree verification only (#{orig_children.size} children)"}
  end
  to_remove = body_calls[1..].sort_by { |n| -n.line_start }
  to_remove.each { |n| lines.delete_at(n.line_start - 1..n.line_end - 1) }
  skeleton = lines.join

  buf = GPCR::Ops::Buffer.new(skeleton)

  # Apply AddOp for each subsequent call.
  orig_children[1..].each_with_index do |gpcr_node, step|
    # Find the last call inside this def in the current buffer.
    cur_def = buf.nodes.find { |n| n.kind == :def && n.name == def_name }
    return {false, "def lost from buffer at step #{step}"} unless cur_def
    cur_def_idx = cur_def.not_nil!.index
    next_cur = buf.nodes.find { |n| n.kind == :def && n.index > cur_def_idx }
    cur_upper = next_cur.try(&.index) || (buf.nodes.size + 1)
    cur_body = buf.nodes.select { |n|
      n.index > cur_def_idx && n.index < cur_upper && n.kind == :call
    }
    last_ln = cur_body.last?
    return {false, "no target call at step #{step}"} unless last_ln

    # Derive slots from GPCR::Node args + the param map.
    call_params = params[gpcr_node.name]? || [] of String
    slots = {} of String => String
    gpcr_node.args.each_with_index do |arg_val, ai|
      pname = call_params[ai]?
      slots[pname] = arg_val if pname
    end

    target = GPCR::Ops::IndexTarget.new(last_ln.not_nil!.index, buf.gen)
    op = GPCR::Ops::AddOp.new(target, gpcr_node.name, slots, "after")
    begin
      buf.apply_one(op)
    rescue e : GPCR::Ops::OpError
      return {false, "AddOp step #{step} failed: #{e.message}"}
    end
  end

  result_prims = {} of String => GPCR::Prim
  result_nodes = GPCR.parse_source(buf.source, result_prims)
  result_def = result_nodes.find { |n| n.name == def_name }
  return {false, "def missing from result"} unless result_def

  equal = nodes_struct_equal?(result_def.not_nil!.children, orig_children)
  {equal, "multi-call (#{orig_children.size} calls) #{equal ? "PASS" : "FAIL — tree mismatch"}"}
end

describe "GPCR self-conformance" do
  root = Path[__DIR__].join("..").normalize.to_s
  gpcr_files = Dir.glob(File.join(root, "src", "gpcr", "*.cr")).sort

  # Parse each file once at spec-registration time to decide which tests to
  # add. This uses Crystal::Parser at runtime (not the Crystal compiler), so
  # it's fast enough for spec setup.
  file_data = gpcr_files.map do |path|
    source = File.read(path)
    prims = {} of String => GPCR::Prim
    nodes = GPCR.parse_source(source, prims)
    {path, source, nodes}
  end

  describe "(a) GPCR.check_blocks sweeps every src/gpcr/*.cr" do
    it "compiles a fixture requiring all src/gpcr/*.cr and GPCR.check_blocks" do
      tmp = File.tempfile("gpcr_conformance", ".cr", dir: __DIR__)
      begin
        # Require each src/gpcr file via path relative to spec/.
        gpcr_files.each do |f|
          rel = Path[f].relative_to(Path[__DIR__]).to_s
          tmp.print %(require #{rel.inspect}\n)
        end
        # main.cr's standard_main macro requires ≥1 @[GPCR::Graph] def at
        # compile time; provide a sentinel so the fixture compiles cleanly.
        tmp.print <<-CR

          @[GPCR::Graph(x: 0, y: 0)]
          def _gpcr_conformance_sentinel
            on_start
          end

          GPCR.check_blocks
          CR
        tmp.flush
        err = IO::Memory.new
        status = Process.run("crystal", ["build", "--no-codegen", tmp.path], error: err)
        status.success?.should be_true, "crystal --no-codegen failed:\n#{err}"
      ensure
        tmp.delete
      end
    end
  end

  describe "(b) self-render: balanced fences and block stacks" do
    file_data.each do |(path, source, nodes)|
      filename = File.basename(path)
      block_nodes = nodes.select { |n| n.kind == :block && !n.children.empty? }

      it "#{filename}: all backtick fences are balanced" do
        md = GPCR::Renderer.new(source).render
        fences_balanced?(md).should be_true, "unbalanced fences in rendered #{filename}"
      end

      unless block_nodes.empty?
        it "#{filename}: at least one @[GPCR::Block] section renders a body stack" do
          md = GPCR::Renderer.new(source).render
          found = block_nodes.any? { |n| section_has_body_stack?(md, n.name) }
          found.should be_true, "no block section had a rendered body stack in #{filename}"
        end
      end
    end
  end

  describe "(c) pikchr fence syntax" do
    if Process.find_executable("fossil")
      file_data.each do |(path, source, _nodes)|
        filename = File.basename(path)

        it "#{filename}: all pikchr fences produce valid SVG" do
          md = GPCR::Renderer.new(source).render
          fences = extract_pikchr_fences(md)
          fences.each do |fence|
            svg_out = IO::Memory.new
            File.tempfile("gpcr_pik", ".pikchr") do |tmp|
              tmp.print(fence)
              tmp.flush
              Process.run("fossil", ["pikchr", tmp.path], output: svg_out)
            end
            svg_out.to_s.should contain("<svg"), "invalid pikchr in #{filename}:\n#{fence}"
          end
        end
      end
    else
      it "pikchr validation (fossil binary absent — pending)" do
        pending!("fossil binary not found; install fossil to enable pikchr syntax checks")
      end
    end
  end

  # ── (d) OP-PRODUCIBILITY ────────────────────────────────────────────────────
  # For every @[GPCR::Graph] and @[GPCR::Block] def in src/gpcr and examples:
  # - scan body for node kinds
  # - gap kinds (no producing op) → OP-COVERAGE GAP table
  # - flat :call bodies → round-trip through Buffer

  example_files = Dir.glob(File.join(root, "examples", "*.cr")).sort
  corpus_file_data = file_data + example_files.map { |path|
    source = File.read(path)
    prims = {} of String => GPCR::Prim
    nodes = begin
      GPCR.parse_source(source, prims)
    rescue
      [] of GPCR::Node
    end
    {path, source, nodes}
  }

  # Collect gap kinds and producible defs at registration time (parse only, no Buffer).
  all_gap_kinds = [] of Symbol
  producible_specs = [] of {String, String, String, Array(GPCR::Node)}

  corpus_file_data.each do |(path, source, nodes)|
    filename = File.basename(path)
    annotated = nodes.select { |n| n.kind == :graph || n.kind == :block }
    annotated.each do |def_node|
      body_kinds = collect_body_kinds(def_node.children + def_node.else_nodes)
      gap_in_def = body_kinds.select { |k| PRODUCIBILITY_GAP_KINDS.includes?(k) }
      if gap_in_def.any?
        all_gap_kinds.concat(gap_in_def)
      else
        producible_specs << {filename, def_node.name, source, def_node.children}
      end
    end
  end
  all_gap_kinds = all_gap_kinds.uniq

  describe "(d) op-producibility: round-trip + op-coverage gap table" do
    it "op-coverage gap table (findings reported, not failures)" do
      gap_sorted = all_gap_kinds.sort_by(&.to_s)
      if gap_sorted.any?
        puts "\n=== OP-COVERAGE GAP TABLE ==="
        puts "Node kinds in renderable def bodies with no direct producing JSON op:"
        gap_sorted.each do |k|
          hint = case k
                 when :c_call then ":c_call — block-passing call; WrapOp composition covers simple cases; direct 'add-c-call' op pending (follow-up ticket: OP-CCALL-ADD)"
                 when :if     then ":if — no 'add-if' op; conditional creation not yet in op set (follow-up ticket: OP-IF-ADD)"
                 when :while  then ":while — no 'add-while' op; loop creation not yet in op set (follow-up ticket: OP-WHILE-ADD)"
                 when :case   then ":case — no 'add-case' op; match expression not yet in op set (follow-up ticket: OP-CASE-ADD)"
                 when :when   then ":when — nested inside :case; covered by OP-CASE-ADD"
                 when :other  then ":other — arbitrary Crystal expression (assignment, operator chain, etc.); no op planned; belongs behind @[GPCR::Primitive]"
                 else              "#{k} — follow-up ticket needed"
                 end
          puts "  #{hint}"
        end
        puts "==="
      end
      # Gaps are a finding (printed), not a spec failure.
      true.should be_true
    end

    # Round-trip test for each def whose body contains only :call nodes.
    producible_specs.each do |(filename, def_name, source, children)|
      it "#{filename}##{def_name}: round-trips body through JSON ops" do
        passed, reason = attempt_round_trip(source, def_name, children)
        passed.should be_true, "#{def_name} round-trip failed: #{reason}"
      end
    end
  end

  # ── (e) CENSUS RATCHET ──────────────────────────────────────────────────────
  # GPCR line percentage and per-file GPCR line counts must never decrease vs
  # the baseline. Def counts are reported as secondary context.

  describe "(e) census ratchet: GPCR line percentage monotone vs baseline" do
    census_files = gpcr_files
    file_stats, totals = GPCR::Census.census(census_files)

    it "current census printed for reference" do
      puts "\n=== GPCR CENSUS (src/gpcr/*.cr) ==="
      file_stats.each do |filename, stats|
        puts "  #{filename}: gpcr_lines=#{stats.gpcr_lines} primitive_lines=#{stats.primitive_lines} native_lines=#{stats.native_lines} total_lines=#{stats.total_lines} gpcr_pct=#{stats.gpcr_pct}% defs(block=#{stats.blocks}, graph=#{stats.graphs}, primitive=#{stats.primitives}, native=#{stats.unannotated})"
      end
      puts "  PROJECT: gpcr_lines=#{totals.gpcr_lines} primitive_lines=#{totals.primitive_lines} native_lines=#{totals.native_lines} total_lines=#{totals.total_lines} gpcr_pct=#{totals.gpcr_pct}% defs(block=#{totals.blocks}, graph=#{totals.graphs}, primitive=#{totals.primitives}, native=#{totals.unannotated})"
      puts "==="
      true.should be_true
    end

    it "project GPCR line percentage and per-file GPCR lines have not decreased vs baseline" do
      baseline_path = File.join(root, "spec", "fixtures", "census_baseline.json")
      unless File.exists?(baseline_path)
        fail "census baseline not found at #{baseline_path} — run `gpcr census > spec/fixtures/census_baseline.json` from repo root to create it"
      end

      baseline = JSON.parse(File.read(baseline_path))
      gpcr_pct = totals.gpcr_pct
      base_pct = baseline["project"]["gpcr_pct"].as_f

      if gpcr_pct < base_pct
        fail "GPCR line percentage DECREASED: baseline=#{base_pct}% current=#{gpcr_pct}% — ratchet violation; do not reduce the fraction of source lines covered by @[GPCR::Graph]/@[GPCR::Block] spans"
      end

      violations = [] of String
      file_stats.each do |filename, stats|
        base_file = baseline["files"][filename]?
        if base_file
          base_gpcr_lines = base_file["gpcr_lines"].as_i
          if stats.gpcr_lines < base_gpcr_lines
            violations << "#{filename}: baseline=#{base_gpcr_lines} current=#{stats.gpcr_lines}"
          end
        end
      end

      unless violations.empty?
        fail "Per-file GPCR line count DECREASED (ratchet violation):\n  #{violations.join("\n  ")}"
      end

      if gpcr_pct > base_pct
        puts "\n=== CENSUS RATCHET: GPCR line percentage increased #{base_pct}% → #{gpcr_pct}% ==="
        puts "Update spec/fixtures/census_baseline.json in the same commit that improves it."
        puts "Run: gpcr census > spec/fixtures/census_baseline.json"
        puts "==="
      end

      true.should be_true
    end
  end
end

CENSUS_BLOCK_SRC = <<-'CRYSTAL'
  @[GPCR::Block(shape: :command, label: "do #{x}", category: "Test")]
  def do_thing(x : Int32)
    foo
  end
  CRYSTAL

CENSUS_PRIM_SRC = <<-'CRYSTAL'
  @[GPCR::Primitive(shape: :reporter, label: "read #{path}", category: "IO")]
  def read_file(path : String) : String
    File.read(path)
  end
  CRYSTAL

describe "GPCR::Census.census_sources" do
  it "counts gpcr_lines for @[GPCR::Block] sources" do
    _, totals = GPCR::Census.census_sources([{"block.cr", CENSUS_BLOCK_SRC}])
    totals.gpcr_lines.should be > 0
    totals.blocks.should eq 1
  end

  it "counts primitive_lines for @[GPCR::Primitive] sources" do
    _, totals = GPCR::Census.census_sources([{"prim.cr", CENSUS_PRIM_SRC}])
    totals.primitive_lines.should be > 0
    totals.primitives.should eq 1
  end

  it "aggregates multiple sources correctly" do
    pairs = [{"block.cr", CENSUS_BLOCK_SRC}, {"prim.cr", CENSUS_PRIM_SRC}]
    file_stats, totals = GPCR::Census.census_sources(pairs)
    file_stats.size.should eq 2
    totals.blocks.should eq 1
    totals.primitives.should eq 1
    totals.total_lines.should eq(file_stats.values.sum(&.total_lines))
  end

  it "returns an empty project stat for an empty pairs list" do
    _, totals = GPCR::Census.census_sources([] of {String, String})
    totals.total_lines.should eq 0
    totals.gpcr_pct.should eq 0.0
  end
end