Class: Moult::Diff

Inherits:
Object
  • Object
show all
Defined in:
lib/moult/diff.rb

Overview

A Moult-owned value object describing what changed between a base ref and the working tree, plus the pure filter the gate uses to decide whether a finding is "in the diff". This is the genuinely novel component of the PR gate — it is pinned against hand-built git output exactly like the coverage Resolver and the ABC metric; drift is a bug.

Git is the only file that shells git; it hands this class raw --name-status and --unified=0 text. Diff.parse turns that text into a Diff with no IO, so it is trivially unit-testable. Diff.compute is the thin IO wrapper that calls git then Diff.parse.

Line ranges are taken from the NEW side of each --unified=0 hunk header (@@ -a,b +c,d @@): with zero context they are precisely the added/changed lines. Paths are repo-root-relative (git's own framing); the gate is meant to run at the repository root, where they line up with Moult's root-relative finding paths.

Defined Under Namespace

Classes: ChangedFile

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(base_ref:, merge_base:, scope:, files:) ⇒ Diff

Returns a new instance of Diff.

Parameters:

  • base_ref (String, nil)

    the requested base ref (nil for :all scope)

  • merge_base (String, nil)

    resolved merge-base sha (nil for :all scope)

  • scope (Symbol)

    :diff (gate the changed lines) or :all (gate everything)

  • files (Array<ChangedFile>)


42
43
44
45
46
47
48
# File 'lib/moult/diff.rb', line 42

def initialize(base_ref:, merge_base:, scope:, files:)
  @base_ref = base_ref
  @merge_base = merge_base
  @scope = scope
  @files = files
  @by_path = files.to_h { |f| [f.path, f] }
end

Instance Attribute Details

#base_refObject (readonly)

Returns the value of attribute base_ref.



36
37
38
# File 'lib/moult/diff.rb', line 36

def base_ref
  @base_ref
end

#filesObject (readonly)

Returns the value of attribute files.



36
37
38
# File 'lib/moult/diff.rb', line 36

def files
  @files
end

#merge_baseObject (readonly)

Returns the value of attribute merge_base.



36
37
38
# File 'lib/moult/diff.rb', line 36

def merge_base
  @merge_base
end

#scopeObject (readonly)

Returns the value of attribute scope.



36
37
38
# File 'lib/moult/diff.rb', line 36

def scope
  @scope
end

Class Method Details

.compute(root:, base_ref:, scope: :diff) ⇒ Diff

Resolve the diff for root against base_ref via Git, then parse.

Parameters:

  • scope (Symbol) (defaults to: :diff)

    :diff or :all (:all yields an all-inclusive Diff)

Returns:

Raises:



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/moult/diff.rb', line 92

def compute(root:, base_ref:, scope: :diff)
  return new(base_ref: nil, merge_base: nil, scope: :all, files: []) if scope == :all

  mb = Git.merge_base(root, base_ref)
  unless mb
    raise Moult::Error,
      "could not resolve a merge-base between #{base_ref.inspect} and HEAD " \
      "(unknown ref, shallow clone, or not a git repository); " \
      "pass --base REF or --scope all"
  end

  parse(
    name_status: Git.diff_name_status(root, mb) || "",
    unified_diff: Git.diff_unified_zero(root, mb) || "",
    base_ref: base_ref,
    merge_base: mb,
    scope: :diff
  )
end

.parse(name_status:, unified_diff:, base_ref:, merge_base:, scope: :diff) ⇒ Diff

Build a Diff from raw git text. PURE — no IO. Pinned in test/test_diff.rb.

Parameters:

  • name_status (String)

    git diff --name-status REF output

  • unified_diff (String)

    git diff --unified=0 REF output

Returns:



80
81
82
83
84
85
86
# File 'lib/moult/diff.rb', line 80

def parse(name_status:, unified_diff:, base_ref:, merge_base:, scope: :diff)
  ranges = parse_unified(utf8(unified_diff))
  files = parse_name_status(utf8(name_status)).map do |path, status|
    ChangedFile.new(path: path, status: status, line_ranges: ranges[path] || [])
  end
  new(base_ref: base_ref, merge_base: merge_base, scope: scope, files: files)
end

Instance Method Details

#in_diff?(path:, start_line: nil, end_line: nil) ⇒ Boolean

Line-level membership: is the span [start_line, end_line] inside the diff? Used where an analysis has lines (complexity methods, dead-code spans, duplication/flag occurrences). With start_line nil this falls back to path-level. Always true under :all scope.

Returns:

  • (Boolean)


55
56
57
58
59
60
61
62
63
# File 'lib/moult/diff.rb', line 55

def in_diff?(path:, start_line: nil, end_line: nil)
  return true if scope == :all
  return includes_path?(path) if start_line.nil?

  file = @by_path[path]
  return false unless file

  file.changed_range?(start_line, end_line || start_line)
end

#includes_path?(path) ⇒ Boolean

Path-level membership: did this file change at all? The fallback where an analysis is file-keyed with no line numbers (boundaries — null symbol_id). Always true under :all scope.

Returns:

  • (Boolean)


69
70
71
72
73
# File 'lib/moult/diff.rb', line 69

def includes_path?(path)
  return true if scope == :all

  @by_path.key?(path)
end