Module: Varar::Core::Drifts

Defined in:
lib/varar/core/drift.rb

Overview

Spec drift detection: a paragraph the committed varar.lock.json baseline recorded as an example that now matches no step. Pure, byte-identical to the TS port so varar.lock.json is shared across languages. Port of drift.ts.

BaselineStore is a duck-typed port: #read -> String|nil, #write(contents).

Constant Summary collapse

SIMILARITY_THRESHOLD =

A paragraph may be moved anywhere and reworded up to ~half its words and still be recognized; edit it past this and it reads as remove+add, not drift. Ported byte-identically.

0.5
TOKEN_RE =
/[[:alnum:]]+/

Class Method Summary collapse

Class Method Details

.derive_spec_baseline(source, var_doc, plan) ⇒ Object



65
66
67
# File 'lib/varar/core/drift.rb', line 65

def derive_spec_baseline(source, var_doc, plan)
  SpecBaseline.new(source_hash: Hash32.hash_source(source), examples: live_examples(var_doc, plan))
end

.detect_drift(baseline, var_doc, plan) ⇒ Object

Paragraphs the baseline recorded as examples that now match zero steps. Each re-identified by the most word-similar current paragraph at/above the threshold (exact name scores 1; ties break toward the nearest line).



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/varar/core/drift.rb', line 72

def detect_drift(baseline, var_doc, plan)
  return [] if baseline.nil?

  candidates = var_doc.examples
  tokens = candidates.map { |c| tokenize(Plan.derive_example_name(c.body)) }
  live = candidates.map { |c| live?(c.span, plan) }

  baseline.examples.filter_map do |b|
    b_tokens = tokenize(b.name)
    best_idx = -1
    best_score = 0.0
    candidates.each_with_index do |candidate, i|
      score = similarity(b_tokens, tokens[i])
      next if score < SIMILARITY_THRESHOLD

      line = candidate.span.start_line
      best_line = best_idx >= 0 ? candidates[best_idx].span.start_line : 0
      next unless best_idx.negative? || score > best_score ||
                  (score == best_score && (line - b.line).abs < (best_line - b.line).abs)

      best_idx = i
      best_score = score
    end
    next if best_idx.negative?
    next if live[best_idx]

    Drift.new(name: b.name, line: candidates[best_idx].span.start_line, span: candidates[best_idx].span)
  end
end

.drift_diagnostics(drifts) ⇒ Object



102
103
104
# File 'lib/varar/core/drift.rb', line 102

def drift_diagnostics(drifts)
  drifts.map { |d| Diagnostics.drift_detected(d.name, d.span) }
end

.live?(candidate_span, plan) ⇒ Boolean

Returns:

  • (Boolean)


38
39
40
# File 'lib/varar/core/drift.rb', line 38

def live?(candidate_span, plan)
  plan.examples.any? { |pe| within?(pe.span, candidate_span) }
end

.live_examples(var_doc, plan) ⇒ Object

The current example-producing paragraphs, in document order.



57
58
59
60
61
62
63
# File 'lib/varar/core/drift.rb', line 57

def live_examples(var_doc, plan)
  var_doc.examples.filter_map do |candidate|
    next unless live?(candidate.span, plan)

    BaselineExample.new(name: Plan.derive_example_name(candidate.body), line: candidate.span.start_line)
  end
end

.parse_baseline_example(value) ⇒ Object



159
160
161
162
163
164
165
166
167
# File 'lib/varar/core/drift.rb', line 159

def parse_baseline_example(value)
  return nil unless value.is_a?(::Hash)

  name = value['name']
  line = value['line']
  return nil unless name.is_a?(String) && line.is_a?(Integer)

  BaselineExample.new(name: name, line: line)
end

.parse_spec_baseline(value) ⇒ Object



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/varar/core/drift.rb', line 142

def parse_spec_baseline(value)
  return nil unless value.is_a?(::Hash)

  source_hash = value['sourceHash']
  examples_raw = value['examples']
  return nil unless source_hash.is_a?(String) && examples_raw.is_a?(Array)

  examples = []
  examples_raw.each do |item|
    parsed = parse_baseline_example(item)
    return nil if parsed.nil?

    examples << parsed
  end
  SpecBaseline.new(source_hash: source_hash, examples: examples)
end

.parse_var_lock(text) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/varar/core/drift.rb', line 123

def parse_var_lock(text)
  parsed = JSON.parse(text)
  return nil unless parsed.is_a?(::Hash) && parsed['version'] == 1

  specs_raw = parsed['specs']
  return nil unless specs_raw.is_a?(::Hash)

  specs = {}
  specs_raw.each do |path, value|
    baseline = parse_spec_baseline(value)
    return nil if baseline.nil?

    specs[path] = baseline
  end
  VarLock.new(version: 1, specs: specs)
rescue JSON::ParserError, TypeError
  nil
end

.reconcile_drift(store, spec_path, source, var_doc, plan, update: false) ⇒ Object

One spec's baseline reconciliation against a BaselineStore. In update mode, accept all drift (re-record, report nothing); otherwise detect drift and rewrite the baseline only on a clean run, so an unacknowledged drift keeps its old entry (and stays red).



110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/varar/core/drift.rb', line 110

def reconcile_drift(store, spec_path, source, var_doc, plan, update: false)
  text = store.read
  lock = text ? parse_var_lock(text) : nil
  baseline = lock ? lock.specs[spec_path] : nil
  drifts = update ? [] : detect_drift(baseline, var_doc, plan)
  if update || drifts.empty?
    specs = lock ? lock.specs.dup : {}
    specs[spec_path] = derive_spec_baseline(source, var_doc, plan)
    store.write(stringify_var_lock(VarLock.new(version: 1, specs: specs)))
  end
  drifts
end

.similarity(set_a, set_b) ⇒ Object

Jaccard overlap |A∩B| / |A∪B|. 1 identical, 0 disjoint; two empty = 1.



48
49
50
51
52
53
54
# File 'lib/varar/core/drift.rb', line 48

def similarity(set_a, set_b)
  return 1.0 if set_a.empty? && set_b.empty?

  intersection = (set_a & set_b).size
  union = set_a.size + set_b.size - intersection
  union.zero? ? 0.0 : intersection.to_f / union
end

.stringify_var_lock(lock) ⇒ Object

Serialize varar.lock.json deterministically: spec paths sorted, examples in document order, insertion-order keys otherwise (version, specs; sourceHash, examples; name, line) — NOT canonical JSON's key sort.



172
173
174
175
176
177
178
179
180
181
182
# File 'lib/varar/core/drift.rb', line 172

def stringify_var_lock(lock)
  specs = {}
  lock.specs.keys.sort.each do |path|
    baseline = lock.specs[path]
    specs[path] = {
      'sourceHash' => baseline.source_hash,
      'examples' => baseline.examples.map { |e| { 'name' => e.name, 'line' => e.line } }
    }
  end
  CanonicalJson.ordered_stringify({ 'version' => 1, 'specs' => specs })
end

.tokenize(text) ⇒ Object

Lower-cased word tokens (letters/digits) — the unit of similarity.



43
44
45
# File 'lib/varar/core/drift.rb', line 43

def tokenize(text)
  text.downcase.scan(TOKEN_RE).to_set
end

.within?(inner, outer) ⇒ Boolean

Returns:

  • (Boolean)


34
35
36
# File 'lib/varar/core/drift.rb', line 34

def within?(inner, outer)
  inner.start_offset >= outer.start_offset && inner.end_offset <= outer.end_offset
end