varar-core 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 840328108129bed1a48c4a3dc86fa1ca4d13937f0e313ccfa41fcaf0a98f89c3
4
- data.tar.gz: 10d2916bda1d520f5521a99ddfd524305170b711fe476359746d83f5fa3f943b
3
+ metadata.gz: 4e14558a60af014ad7a1e7edfea61644a415408c5da1e384d6c34d4f191a2743
4
+ data.tar.gz: f91c617e21f8affcd745676fffba8be6b1ab484ec9c2c4e17b7173d6d07e1733
5
5
  SHA512:
6
- metadata.gz: 579d8c9e3dbb19fb6b29165dd8ba075f9ec4d6cf75808648ffd0bbc49e9d5265c7864b784d5d79a29a1d66f530d4fc5b5b3739c434e60f4541e196421e5c402d
7
- data.tar.gz: 42376d608744ce23fd97dc740c024d9432addf7b039a0ddcaa73eceab320c159ea06797e2729c13b4005d76c82a4fefde45af7cdebcaa8eacb1c5cf93a1499cf
6
+ metadata.gz: 4a89ff743d9d00027565cad5933ad28f2bc05af437a773feeac38f9c7c640ee25d548af761852343259afed64654205360e5e2983df9fbd82bc14a1167746013
7
+ data.tar.gz: 66c9b893012bd279f337db321fbae0262802bf579f627a7d7e0db37ff4183b3ec3112c413af5614481521d61543ca2e634b966d54260032615cf0c97057fd89f
@@ -40,8 +40,12 @@ module Varar
40
40
  def kind = 'thematic_break'
41
41
  end
42
42
 
43
- Example = Data.define(:scope_stack, :span, :body)
43
+ # +preceded_by_delimiter+ is true when a heading or thematic break (`---`)
44
+ # sits between this candidate and the previous one (also true for the first
45
+ # candidate). The planner uses it to group adjacent matching candidates into
46
+ # one example. See ADR 0012.
47
+ Example = Data.define(:scope_stack, :span, :body, :preceded_by_delimiter)
44
48
 
45
- VarDoc = Data.define(:path, :source, :examples, :orphan_attachments)
49
+ Doc = Data.define(:path, :source, :examples, :orphan_attachments)
46
50
  end
47
51
  end
@@ -4,39 +4,39 @@ require 'json'
4
4
 
5
5
  module Varar
6
6
  module Core
7
- # JSON serializers byte-for-byte compatible with JS `JSON.stringify(v, null, 2)`:
7
+ # Writes varar.lock.json the way JS `JSON.stringify(v, null, 2)` does:
8
8
  # 2-space indent, LF, trailing newline, non-ASCII raw, empty containers as
9
- # {}/[]. `canonical_stringify` recursively sorts object keys (the goldens);
10
- # `ordered_stringify` preserves insertion order (varar.lock.json).
9
+ # {}/[], keys in insertion order.
11
10
  #
12
- # The container layout is hand-rolled because Ruby's JSON.pretty_generate
13
- # renders empty arrays/objects as "[\n\n]". Scalar encoding is delegated to
14
- # the stdlib, which matches JS (escapes " \ control chars, keeps non-ASCII raw).
11
+ # A committed, language-shared file, so the layout is hand-rolled rather
12
+ # than left to JSON.pretty_generate, which renders empty arrays/objects as
13
+ # "[\n\n]" a Ruby run would otherwise churn the file against every other
14
+ # port's. Scalar encoding is delegated to the stdlib, which matches JS
15
+ # (escapes " \ control chars, keeps non-ASCII raw).
16
+ #
17
+ # Conformance goldens are NOT compared through here: a port has to agree
18
+ # with the goldens' CONTENT, and every spec parses them and compares deep
19
+ # equality.
15
20
  module CanonicalJson
16
21
  module_function
17
22
 
18
- def canonical_stringify(value)
19
- "#{encode(value, '', sort_keys: true)}\n"
20
- end
21
-
22
23
  def ordered_stringify(value)
23
- "#{encode(value, '', sort_keys: false)}\n"
24
+ "#{encode(value, '')}\n"
24
25
  end
25
26
 
26
- def encode(value, indent, sort_keys:)
27
+ def encode(value, indent)
27
28
  case value
28
29
  when Hash
29
30
  return '{}' if value.empty?
30
31
 
31
- keys = sort_keys ? value.keys.sort : value.keys
32
32
  inner = "#{indent} "
33
- items = keys.map { |key| "#{inner}#{key.to_s.to_json}: #{encode(value[key], inner, sort_keys: sort_keys)}" }
33
+ items = value.keys.map { |key| "#{inner}#{key.to_s.to_json}: #{encode(value[key], inner)}" }
34
34
  "{\n#{items.join(",\n")}\n#{indent}}"
35
35
  when Array
36
36
  return '[]' if value.empty?
37
37
 
38
38
  inner = "#{indent} "
39
- items = value.map { |element| "#{inner}#{encode(element, inner, sort_keys: sort_keys)}" }
39
+ items = value.map { |element| "#{inner}#{encode(element, inner)}" }
40
40
  "[\n#{items.join(",\n")}\n#{indent}]"
41
41
  else
42
42
  value.to_json
@@ -5,7 +5,11 @@ module Varar
5
5
  # One checked column of one header-bound row: the cell text and its span.
6
6
  RowCheck = Data.define(:column, :value, :span)
7
7
 
8
- # The verdict for one checked column after comparing against the table.
8
+ # The verdict for one comparison of one CELL the atomic value a sensor
9
+ # checks against the document. A cell is a table cell, a header-bound row's
10
+ # cell, or a value captured from a paragraph by an expression parameter; all
11
+ # three land here. `column` labels the cell (a header cell's text, or `arg N`
12
+ # for an inline capture).
9
13
  # expected_value/actual_value/formatted are adapter-facing, never serialized.
10
14
  CellDiff = Data.define(:column, :span, :expected, :actual, :ok,
11
15
  :expected_value, :actual_value, :formatted) do
@@ -18,7 +22,8 @@ module Varar
18
22
  # The step returned the wrong type/shape — an author mistake, not a value diff.
19
23
  class ReturnShapeError < StandardError; end
20
24
 
21
- # Raised when a header-bound row's / a table's returned columns don't match.
25
+ # Raised when one or more compared CELLS differ an inline capture, a table
26
+ # cell, or a header-bound row's cell.
22
27
  class CellMismatchError < StandardError
23
28
  attr_reader :cells
24
29
 
@@ -93,12 +93,13 @@ module Varar
93
93
  {
94
94
  'scopeStack' => example.scope_stack,
95
95
  'span' => span_hash(example.span),
96
- 'body' => example.body.map { |b| block_hash(b) }
96
+ 'body' => example.body.map { |b| block_hash(b) },
97
+ 'precededByDelimiter' => example.preceded_by_delimiter
97
98
  }
98
99
  end
99
100
 
100
- # Project a VarDoc to the wire dict for the var-doc artifact.
101
- def to_var_doc_artifact(doc)
101
+ # Project a Doc to the wire dict for the var-doc artifact.
102
+ def to_doc_artifact(doc)
102
103
  {
103
104
  'path' => doc.path,
104
105
  'examples' => doc.examples.map { |ex| example_hash(ex) },
@@ -136,7 +137,7 @@ module Varar
136
137
 
137
138
  # Project an ExecutionPlan to the wire dict for the plan artifact.
138
139
  def to_plan_artifact(plan)
139
- source = plan.var_doc.source
140
+ source = plan.doc.source
140
141
  {
141
142
  'examples' => plan.examples.map { |ex| planned_example_hash(ex, source) },
142
143
  'diagnostics' => plan.diagnostics.map do |d|
@@ -195,16 +196,6 @@ module Varar
195
196
  { 'column' => c.column, 'expected' => c.expected, 'actual' => c.actual, 'span' => span_hash(c.span) }
196
197
  end
197
198
  }
198
- when DocStringMismatchError
199
- {
200
- 'kind' => 'doc-string-mismatch', 'line' => line, 'anchor' => anchor,
201
- 'message' => error.message,
202
- 'diff' => {
203
- 'expected' => error.diff.expected,
204
- 'actual' => error.diff.actual,
205
- 'span' => span_hash(error.diff.span)
206
- }
207
- }
208
199
  when ReturnShapeError
209
200
  { 'kind' => 'return-shape', 'line' => line, 'anchor' => anchor, 'message' => error.message }
210
201
  when UnexpectedPassError
@@ -215,8 +206,8 @@ module Varar
215
206
  end
216
207
 
217
208
  # Run all examples and return the four-artifact bundle. Port of runConformance.
218
- def run_conformance(var_doc, registry, create_context, parameter_types = [])
219
- execution = Plan.plan(var_doc, registry)
209
+ def run_conformance(doc, registry, create_context, parameter_types = [])
210
+ execution = Plan.plan(doc, registry)
220
211
  observed = Hash.new { |h, k| h[k] = [] }
221
212
  observer = ->(o) { observed[o.example_index] << o }
222
213
  queue = Execute.collect_examples(execution, create_context: create_context, observer: observer)
@@ -253,7 +244,7 @@ module Varar
253
244
  end
254
245
 
255
246
  {
256
- var_doc: to_var_doc_artifact(var_doc),
247
+ doc: to_doc_artifact(doc),
257
248
  registry: to_registry_artifact(registry, parameter_types),
258
249
  plan: to_plan_artifact(execution),
259
250
  trace: { 'examples' => trace_examples }
@@ -4,32 +4,30 @@ require 'varar/core/cell_diff'
4
4
 
5
5
  module Varar
6
6
  module Core
7
- # A doc-string content difference: fence body span, expected, actual.
8
- DocStringDiff = Data.define(:span, :expected, :actual)
9
-
10
- # Raised when a doc-string step's returned string differs from the content.
11
- class DocStringMismatchError < StandardError
12
- attr_reader :diff
13
-
14
- def initialize(diff)
15
- @diff = diff
16
- super("doc string: expected #{diff.expected.inspect} but was #{diff.actual.inspect}")
17
- end
18
- end
7
+ # The column label a doc-string cell carries in a CellDiff, so its mismatch
8
+ # message reads `doc string: expected … but was …`.
9
+ DOC_STRING_COLUMN = 'doc string'
19
10
 
20
11
  # Pure comparison of a doc-string step's return against the fence body.
21
12
  # Port of doc-string-diff.ts.
22
13
  module DocStringDiffs
23
14
  module_function
24
15
 
25
- # nil no check; equal string nil (pass); unequal DocStringDiff;
16
+ # A doc string is ONE CELL, compared whole, so a difference is an ordinary
17
+ # CellDiff and the executor raises the same CellMismatchError as any other
18
+ # cell. `expected`/`actual` are quoted: a doc string routinely differs only
19
+ # in whitespace, and bare text would render a missing trailing newline as
20
+ # no difference at all.
21
+ #
22
+ # nil → no check; equal string → nil (pass); unequal → CellDiff;
26
23
  # non-string → ReturnShapeError.
27
24
  def compare_doc_string(returned, content, span)
28
25
  return nil if returned.nil?
29
26
  raise ReturnShapeError, "expected a doc string (string), got #{returned.class}" unless returned.is_a?(String)
30
27
  return nil if returned == content
31
28
 
32
- DocStringDiff.new(span: span, expected: content, actual: returned)
29
+ CellDiff.new(column: DOC_STRING_COLUMN, span: span, expected: content.inspect,
30
+ actual: returned.inspect, ok: false)
33
31
  end
34
32
  end
35
33
  end
@@ -10,14 +10,14 @@ module Varar
10
10
  module Core
11
11
  # One example-producing paragraph, as recorded in the baseline.
12
12
  BaselineExample = Data.define(:name, :line)
13
- # The committed baseline for one spec file.
14
- SpecBaseline = Data.define(:source_hash, :examples)
15
- # The whole varar.lock.json: every spec keyed by its POSIX path.
16
- VarLock = Data.define(:version, :specs)
13
+ # The committed baseline for one oath file.
14
+ OathBaseline = Data.define(:source_hash, :examples)
15
+ # The whole varar.lock.json: every oath keyed by its POSIX path.
16
+ LockFile = Data.define(:version, :oaths)
17
17
  # A paragraph the baseline says was an example and now matches no step.
18
18
  Drift = Data.define(:name, :line, :span)
19
19
 
20
- # Spec drift detection: a paragraph the committed varar.lock.json baseline
20
+ # Oath drift detection: a paragraph the committed varar.lock.json baseline
21
21
  # recorded as an example that now matches no step. Pure, byte-identical to
22
22
  # the TS port so varar.lock.json is shared across languages. Port of drift.ts.
23
23
  #
@@ -31,12 +31,20 @@ module Varar
31
31
 
32
32
  module_function
33
33
 
34
- def within?(inner, outer)
35
- inner.start_offset >= outer.start_offset && inner.end_offset <= outer.end_offset
34
+ # Do the two spans overlap at all (offset ranges intersect)? A candidate
35
+ # paragraph relates to its planned example either way round: a header-bound
36
+ # row sits inside its binding paragraph, while a merged example's span
37
+ # covers each candidate it absorbed (ADR 0012). Overlap catches both.
38
+ def overlaps?(span_a, span_b)
39
+ span_a.start_offset < span_b.end_offset && span_b.start_offset < span_a.end_offset
36
40
  end
37
41
 
42
+ # A candidate is "live" (still an example) if it overlaps at least one
43
+ # planned example. A now-prose paragraph — one whose step def was renamed
44
+ # or deleted — overlaps none (it became a delimiter, splitting any example
45
+ # it was part of), so drift catches it.
38
46
  def live?(candidate_span, plan)
39
- plan.examples.any? { |pe| within?(pe.span, candidate_span) }
47
+ plan.examples.any? { |pe| overlaps?(pe.span, candidate_span) }
40
48
  end
41
49
 
42
50
  # Lower-cased word tokens (letters/digits) — the unit of similarity.
@@ -54,25 +62,25 @@ module Varar
54
62
  end
55
63
 
56
64
  # The current example-producing paragraphs, in document order.
57
- def live_examples(var_doc, plan)
58
- var_doc.examples.filter_map do |candidate|
65
+ def live_examples(doc, plan)
66
+ doc.examples.filter_map do |candidate|
59
67
  next unless live?(candidate.span, plan)
60
68
 
61
69
  BaselineExample.new(name: Plan.derive_example_name(candidate.body), line: candidate.span.start_line)
62
70
  end
63
71
  end
64
72
 
65
- def derive_spec_baseline(source, var_doc, plan)
66
- SpecBaseline.new(source_hash: Hash32.hash_source(source), examples: live_examples(var_doc, plan))
73
+ def derive_oath_baseline(source, doc, plan)
74
+ OathBaseline.new(source_hash: Hash32.hash_source(source), examples: live_examples(doc, plan))
67
75
  end
68
76
 
69
77
  # Paragraphs the baseline recorded as examples that now match zero steps.
70
78
  # Each re-identified by the most word-similar current paragraph at/above
71
79
  # the threshold (exact name scores 1; ties break toward the nearest line).
72
- def detect_drift(baseline, var_doc, plan)
80
+ def detect_drift(baseline, doc, plan)
73
81
  return [] if baseline.nil?
74
82
 
75
- candidates = var_doc.examples
83
+ candidates = doc.examples
76
84
  tokens = candidates.map { |c| tokenize(Plan.derive_example_name(c.body)) }
77
85
  live = candidates.map { |c| live?(c.span, plan) }
78
86
 
@@ -103,43 +111,76 @@ module Varar
103
111
  drifts.map { |d| Diagnostics.drift_detected(d.name, d.span) }
104
112
  end
105
113
 
106
- # One spec's baseline reconciliation against a BaselineStore. In update
114
+ # One oath's baseline reconciliation against a BaselineStore. In update
107
115
  # mode, accept all drift (re-record, report nothing); otherwise detect
108
116
  # drift and rewrite the baseline only on a clean run, so an unacknowledged
109
117
  # drift keeps its old entry (and stays red).
110
- def reconcile_drift(store, spec_path, source, var_doc, plan, update: false)
118
+ def reconcile_drift(store, oath_path, source, doc, plan, update: false)
111
119
  text = store.read
112
- lock = text ? parse_var_lock(text) : nil
113
- baseline = lock ? lock.specs[spec_path] : nil
114
- drifts = update ? [] : detect_drift(baseline, var_doc, plan)
120
+ lock = text ? parse_lock_file(text) : nil
121
+ baseline = lock ? lock.oaths[oath_path] : nil
122
+ drifts = update ? [] : detect_drift(baseline, doc, plan)
115
123
  if update || drifts.empty?
116
- specs = lock ? lock.specs.dup : {}
117
- specs[spec_path] = derive_spec_baseline(source, var_doc, plan)
118
- store.write(stringify_var_lock(VarLock.new(version: 1, specs: specs)))
124
+ oaths = lock ? lock.oaths.dup : {}
125
+ oaths[oath_path] = derive_oath_baseline(source, doc, plan)
126
+ store.write(stringify_lock_file(LockFile.new(version: 2, oaths: oaths)))
119
127
  end
120
128
  drifts
121
129
  end
122
130
 
123
- def parse_var_lock(text)
131
+ # Drop every baseline whose oath path is not in +keep_paths+ — the entries
132
+ # left behind when an oath is deleted or moved. Pure counterpart of
133
+ # parse_lock_file / stringify_lock_file; the caller decides what "still
134
+ # exists" means.
135
+ def prune_lock_file(lock, keep_paths)
136
+ keep = keep_paths.to_a
137
+ LockFile.new(version: 2, oaths: lock.oaths.slice(*keep))
138
+ end
139
+
140
+ # The whole-lock counterpart of reconcile_drift, run ONCE per run rather
141
+ # than per oath: reconciliation cannot see paths that no longer exist, so
142
+ # without this the lock silently accumulates dead entries and stops being
143
+ # a faithful inventory of the oath set (#70).
144
+ #
145
+ # +keep_paths+ MUST be everything the +docs+ globs currently match — never
146
+ # the set the run happened to execute. Runs are routinely filtered, and
147
+ # pruning against a filtered set would delete live baselines.
148
+ #
149
+ # Removal is still not *gated*: a deleted oath is a different signal from
150
+ # drift and stays ungated (ADR 0002). This only stops preserving dead
151
+ # state, and only under +update+. Returns the paths removed (or, without
152
+ # +update+, the ones that would be).
153
+ def prune_baselines(store, keep_paths, update: false)
154
+ text = store.read
155
+ lock = text ? parse_lock_file(text) : nil
156
+ return [] unless lock
157
+
158
+ keep = keep_paths.to_a
159
+ stale = lock.oaths.keys.reject { |path| keep.include?(path) }
160
+ store.write(stringify_lock_file(prune_lock_file(lock, keep))) if update && !stale.empty?
161
+ stale
162
+ end
163
+
164
+ def parse_lock_file(text)
124
165
  parsed = JSON.parse(text)
125
- return nil unless parsed.is_a?(::Hash) && parsed['version'] == 1
166
+ return nil unless parsed.is_a?(::Hash) && parsed['version'] == 2
126
167
 
127
- specs_raw = parsed['specs']
128
- return nil unless specs_raw.is_a?(::Hash)
168
+ oaths_raw = parsed['oaths']
169
+ return nil unless oaths_raw.is_a?(::Hash)
129
170
 
130
- specs = {}
131
- specs_raw.each do |path, value|
132
- baseline = parse_spec_baseline(value)
171
+ oaths = {}
172
+ oaths_raw.each do |path, value|
173
+ baseline = parse_oath_baseline(value)
133
174
  return nil if baseline.nil?
134
175
 
135
- specs[path] = baseline
176
+ oaths[path] = baseline
136
177
  end
137
- VarLock.new(version: 1, specs: specs)
178
+ LockFile.new(version: 2, oaths: oaths)
138
179
  rescue JSON::ParserError, TypeError
139
180
  nil
140
181
  end
141
182
 
142
- def parse_spec_baseline(value)
183
+ def parse_oath_baseline(value)
143
184
  return nil unless value.is_a?(::Hash)
144
185
 
145
186
  source_hash = value['sourceHash']
@@ -153,7 +194,7 @@ module Varar
153
194
 
154
195
  examples << parsed
155
196
  end
156
- SpecBaseline.new(source_hash: source_hash, examples: examples)
197
+ OathBaseline.new(source_hash: source_hash, examples: examples)
157
198
  end
158
199
 
159
200
  def parse_baseline_example(value)
@@ -166,19 +207,19 @@ module Varar
166
207
  BaselineExample.new(name: name, line: line)
167
208
  end
168
209
 
169
- # Serialize varar.lock.json deterministically: spec paths sorted, examples
170
- # in document order, insertion-order keys otherwise (version, specs;
210
+ # Serialize varar.lock.json deterministically: oath paths sorted, examples
211
+ # in document order, insertion-order keys otherwise (version, oaths;
171
212
  # sourceHash, examples; name, line) — NOT canonical JSON's key sort.
172
- def stringify_var_lock(lock)
173
- specs = {}
174
- lock.specs.keys.sort.each do |path|
175
- baseline = lock.specs[path]
176
- specs[path] = {
213
+ def stringify_lock_file(lock)
214
+ oaths = {}
215
+ lock.oaths.keys.sort.each do |path|
216
+ baseline = lock.oaths[path]
217
+ oaths[path] = {
177
218
  'sourceHash' => baseline.source_hash,
178
219
  'examples' => baseline.examples.map { |e| { 'name' => e.name, 'line' => e.line } }
179
220
  }
180
221
  end
181
- CanonicalJson.ordered_stringify({ 'version' => 1, 'specs' => specs })
222
+ CanonicalJson.ordered_stringify({ 'version' => 2, 'oaths' => oaths })
182
223
  end
183
224
  end
184
225
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'varar/core/span'
4
- require 'varar/core/deep_freeze'
5
4
  require 'varar/core/cell_diff'
6
5
  require 'varar/core/doc_string_diff'
7
6
  require 'varar/core/param_diff'
@@ -43,17 +42,17 @@ module Varar
43
42
  def execute_plan(plan, sink:, create_context:, observer: nil, reporter: nil)
44
43
  plan.diagnostics.each { |d| reporter.call(d) } if reporter
45
44
  create_ctx = create_context || ->(_file) { {} }
46
- var_path = plan.var_doc.path
45
+ oath_path = plan.doc.path
47
46
 
48
47
  plan.examples.each_with_index do |ex, example_index|
49
48
  seen_lines = {}
50
49
  ex.steps.each { |s| seen_lines[s.match_span.start_line] = true }
51
50
  info = { lines: seen_lines.keys }
52
- sink.call(ex.name, build_run(plan, ex, example_index, create_ctx, observer, var_path), info)
51
+ sink.call(ex.name, build_run(plan, ex, example_index, create_ctx, observer, oath_path), info)
53
52
  end
54
53
  end
55
54
 
56
- def build_run(plan, ex, example_index, create_ctx, observer, var_path)
55
+ def build_run(plan, ex, example_index, create_ctx, observer, oath_path)
57
56
  lambda do
58
57
  state_by_file = {}
59
58
  last_return = nil
@@ -61,7 +60,7 @@ module Varar
61
60
 
62
61
  ex.steps.each_with_index do |step, i|
63
62
  file = step.step_def.expression_source_file
64
- state_by_file[file] = DeepFreeze.deep_freeze(create_ctx.call(file)) unless state_by_file.key?(file)
63
+ state_by_file[file] = create_ctx.call(file) unless state_by_file.key?(file)
65
64
  state = state_by_file[file]
66
65
 
67
66
  extra = []
@@ -76,9 +75,12 @@ module Varar
76
75
  last_return = returned
77
76
  case step.step_def.kind
78
77
  when 'stimulus'
79
- # Full replacement: the returned Hash IS the next state (deep-frozen).
80
- # There is no merge — a return with fewer keys shrinks the state.
81
- # nil is a no-op; any other type is a contract violation.
78
+ # Full replacement: the returned Hash IS the next state. There is
79
+ # no merge — a return with fewer keys shrinks the state. nil is a
80
+ # no-op; any other type is a contract violation.
81
+ #
82
+ # The state is the author's own value, handed back untouched: we
83
+ # do not freeze it. Whether it is immutable is the author's call.
82
84
  unless returned.nil?
83
85
  unless returned.is_a?(Hash)
84
86
  raise ReturnShapeError,
@@ -86,16 +88,16 @@ module Varar
86
88
  'or nothing to leave it unchanged'
87
89
  end
88
90
 
89
- state = DeepFreeze.deep_freeze(returned)
91
+ state = returned
90
92
  state_by_file[file] = state
91
93
  end
92
94
  when 'sensor'
93
- compare_sensor_return(plan, ex, step, returned, extra) if ex.row_checks.nil? && !returned.nil?
95
+ compare_sensor_return(plan, ex, step, returned, extra) if ex.row_checks.nil?
94
96
  else
95
97
  raise ReturnShapeError, "unknown step kind: #{step.step_def.kind}"
96
98
  end
97
99
  rescue StandardError => e
98
- augmented = augment_stack(e, step, var_path)
100
+ augmented = augment_stack(e, step, oath_path)
99
101
  observer&.call(observation(ex, example_index, i + 1, file, 'fail', augmented))
100
102
  thrown = augmented
101
103
  break
@@ -106,10 +108,16 @@ module Varar
106
108
 
107
109
  # Header-bound row checks (after all steps).
108
110
  if thrown.nil? && ex.row_checks && !ex.row_checks.empty?
111
+ # Like a slotted sensor, a header-bound row step must answer the row
112
+ # it is bound to: no return means nothing was compared.
113
+ row_error = if last_return.nil?
114
+ ReturnShapeError.new('a header-bound row step must return a row object ' \
115
+ 'with one value per bound cell, got nothing')
116
+ end
109
117
  bad = CellDiffs.compare_row(last_return, ex.row_checks).reject(&:ok)
110
- unless bad.empty?
118
+ if row_error || !bad.empty?
111
119
  last_step = ex.steps.last
112
- augmented = augment_stack(CellMismatchError.new(bad), last_step, var_path)
120
+ augmented = augment_stack(row_error || CellMismatchError.new(bad), last_step, oath_path)
113
121
  observer&.call(observation(ex, example_index, ex.steps.length,
114
122
  last_step.step_def.expression_source_file, 'fail', augmented))
115
123
  thrown = augmented
@@ -121,7 +129,7 @@ module Varar
121
129
  if thrown.nil?
122
130
  error = UnexpectedPassError.new
123
131
  last = ex.steps.last
124
- raise(last ? augment_stack(error, last, var_path) : error)
132
+ raise(last ? augment_stack(error, last, oath_path) : error)
125
133
  end
126
134
  raise thrown if ex.expected_error_message && !thrown.message.include?(ex.expected_error_message)
127
135
 
@@ -133,20 +141,30 @@ module Varar
133
141
  end
134
142
 
135
143
  # Sensor slot contract: zero slots + a return is a mistake; one slot IS
136
- # the return; two+ is a positional array. Raises the appropriate diff error.
144
+ # the return; two+ is a positional array. With one or more slots the
145
+ # return is REQUIRED — returning nothing used to skip the comparison
146
+ # silently, so a typo turned an assertion into a no-op. Raises the
147
+ # appropriate diff error.
137
148
  def compare_sensor_return(plan, _ex, step, returned, extra)
138
149
  slot_count = step.args.length + extra.length
139
150
  if slot_count.zero?
151
+ return if returned.nil?
152
+
140
153
  raise ReturnShapeError, 'this sensor has no parameters, data table or doc string — ' \
141
154
  'nothing to compare a return value against (raise to fail, return nothing to pass)'
142
155
  end
143
156
 
157
+ if returned.nil?
158
+ raise ReturnShapeError,
159
+ "a sensor with #{slot_count} slot(s) must return one value per slot, got nothing"
160
+ end
161
+
144
162
  if slot_count == 1
145
163
  slots = [returned]
146
164
  else
147
165
  unless returned.is_a?(Array)
148
166
  raise ReturnShapeError,
149
- "a sensor with #{slot_count} parameters must return a list of " \
167
+ "a sensor with #{slot_count} slots must return a list of " \
150
168
  "#{slot_count} values, got #{returned.class}"
151
169
  end
152
170
  unless returned.length == slot_count
@@ -159,7 +177,7 @@ module Varar
159
177
 
160
178
  inline_returned = slots[0...step.args.length]
161
179
  source_texts = step.param_spans.map do |s|
162
- Offsets.utf16_slice(plan.var_doc.source, s.start_offset, s.end_offset)
180
+ Offsets.utf16_slice(plan.doc.source, s.start_offset, s.end_offset)
163
181
  end
164
182
  param_diffs = ParamDiff.compare_params(inline_returned, step.args, step.param_spans, source_texts,
165
183
  step.formats).reject(&:ok)
@@ -171,7 +189,7 @@ module Varar
171
189
  elsif step.doc_string
172
190
  diff = DocStringDiffs.compare_doc_string(slots[step.args.length], step.doc_string.content,
173
191
  step.doc_string.span)
174
- raise DocStringMismatchError, diff unless diff.nil?
192
+ raise CellMismatchError, [diff] unless diff.nil?
175
193
  end
176
194
  end
177
195
 
@@ -181,9 +199,12 @@ module Varar
181
199
  end
182
200
 
183
201
  # In TS this injects a synthetic `at <text> (path:line:col)` frame for
184
- # editor navigation; the conformance trace derives the anchor separately
185
- # via failure_anchor, so here it is a no-op that returns the error.
186
- def augment_stack(error, _step, _var_path)
202
+ # editor navigation. Ruby has no writable stack text to splice into, so
203
+ # it records the anchor structurally instead the failing step's span
204
+ # (or the first mismatched cell's), which Failures.to_failure reads back
205
+ # so a renderer underlines the step and not its whole line.
206
+ def augment_stack(error, step, _var_path)
207
+ FailureAnchor.attach_anchor(error, FailureAnchor.failure_anchor(error, step.match_span))
187
208
  error
188
209
  end
189
210
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'varar/core/cell_diff'
4
+ require 'varar/core/failure_anchor'
5
+ require 'varar/core/result'
6
+
7
+ module Varar
8
+ module Core
9
+ # Converts a caught step error into the structured ExampleFailure payload —
10
+ # port of failure.ts / failure.py / Failure.java / failure.rs. Shared by
11
+ # every producer so failures are byte-identical across ports.
12
+ #
13
+ # Where TS scrapes an injected `<path>:line:col` stack frame for the failing
14
+ # line, Ruby reads it off the anchor the executor attached (the Rust port
15
+ # does the same): a Ruby backtrace has no synthetic frame to scrape, and the
16
+ # anchor already carries the line the frame would have named.
17
+ module Failures
18
+ module_function
19
+
20
+ # A caught step error → the ExampleResult.failure payload.
21
+ #
22
+ # `fallback_line` is used when the error carries no anchor, i.e. it never
23
+ # passed through a step.
24
+ def to_failure(error, _oath_path, fallback_line)
25
+ anchor = FailureAnchor.attached_anchor(error)
26
+
27
+ ExampleFailure.new(
28
+ line: anchor ? anchor.start_line : fallback_line,
29
+ message: error.message,
30
+ stack: render_stack(error),
31
+ cells: failing_cells(error),
32
+ anchor: anchor && AnchorRange.new(from: anchor.start_offset, to: anchor.end_offset)
33
+ )
34
+ end
35
+
36
+ # Every mismatched cell — table, header-bound row, inline capture or doc
37
+ # string. nil (not an empty array) when the error is not a mismatch, so
38
+ # the key stays absent in the serialized payload.
39
+ def failing_cells(error)
40
+ return nil unless error.is_a?(CellMismatchError)
41
+
42
+ failing = error.cells.reject(&:ok).map do |c|
43
+ CellFailure.new(from: c.span.start_offset, to: c.span.end_offset, actual: c.actual)
44
+ end
45
+ failing.empty? ? nil : failing
46
+ end
47
+
48
+ # Display-only: the message plus Ruby's own backtrace. Runtime-shaped by
49
+ # design (ADR 0014) — no consumer parses it.
50
+ def render_stack(error)
51
+ ([error.message] + Array(error.backtrace)).join("\n")
52
+ end
53
+ end
54
+ end
55
+ end