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 +4 -4
- data/lib/varar/core/ast.rb +6 -2
- data/lib/varar/core/canonical_json.rb +15 -15
- data/lib/varar/core/cell_diff.rb +7 -2
- data/lib/varar/core/conformance.rb +8 -17
- data/lib/varar/core/doc_string_diff.rb +12 -14
- data/lib/varar/core/drift.rb +82 -41
- data/lib/varar/core/execute.rb +42 -21
- data/lib/varar/core/failure.rb +55 -0
- data/lib/varar/core/failure_anchor.rb +20 -2
- data/lib/varar/core/param_diff.rb +1 -1
- data/lib/varar/core/parse.rb +1 -1
- data/lib/varar/core/plan.rb +210 -133
- data/lib/varar/core/registry.rb +32 -4
- data/lib/varar/core/result.rb +78 -0
- data/lib/varar/core/structurer.rb +19 -25
- data/lib/varar/core.rb +3 -2
- metadata +4 -3
- data/lib/varar/core/deep_freeze.rb +0 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e14558a60af014ad7a1e7edfea61644a415408c5da1e384d6c34d4f191a2743
|
|
4
|
+
data.tar.gz: f91c617e21f8affcd745676fffba8be6b1ab484ec9c2c4e17b7173d6d07e1733
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a89ff743d9d00027565cad5933ad28f2bc05af437a773feeac38f9c7c640ee25d548af761852343259afed64654205360e5e2983df9fbd82bc14a1167746013
|
|
7
|
+
data.tar.gz: 66c9b893012bd279f337db321fbae0262802bf579f627a7d7e0db37ff4183b3ec3112c413af5614481521d61543ca2e634b966d54260032615cf0c97057fd89f
|
data/lib/varar/core/ast.rb
CHANGED
|
@@ -40,8 +40,12 @@ module Varar
|
|
|
40
40
|
def kind = 'thematic_break'
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
# {}/[]
|
|
10
|
-
# `ordered_stringify` preserves insertion order (varar.lock.json).
|
|
9
|
+
# {}/[], keys in insertion order.
|
|
11
10
|
#
|
|
12
|
-
#
|
|
13
|
-
# renders empty arrays/objects as
|
|
14
|
-
#
|
|
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, ''
|
|
24
|
+
"#{encode(value, '')}\n"
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
def encode(value, indent
|
|
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
|
|
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
|
|
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
|
data/lib/varar/core/cell_diff.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
101
|
-
def
|
|
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.
|
|
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(
|
|
219
|
-
execution = Plan.plan(
|
|
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
|
-
|
|
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
|
-
#
|
|
8
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/varar/core/drift.rb
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
# The whole varar.lock.json: every
|
|
16
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
35
|
-
|
|
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|
|
|
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(
|
|
58
|
-
|
|
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
|
|
66
|
-
|
|
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,
|
|
80
|
+
def detect_drift(baseline, doc, plan)
|
|
73
81
|
return [] if baseline.nil?
|
|
74
82
|
|
|
75
|
-
candidates =
|
|
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
|
|
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,
|
|
118
|
+
def reconcile_drift(store, oath_path, source, doc, plan, update: false)
|
|
111
119
|
text = store.read
|
|
112
|
-
lock = text ?
|
|
113
|
-
baseline = lock ? lock.
|
|
114
|
-
drifts = update ? [] : detect_drift(baseline,
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
store.write(
|
|
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
|
-
|
|
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'] ==
|
|
166
|
+
return nil unless parsed.is_a?(::Hash) && parsed['version'] == 2
|
|
126
167
|
|
|
127
|
-
|
|
128
|
-
return nil unless
|
|
168
|
+
oaths_raw = parsed['oaths']
|
|
169
|
+
return nil unless oaths_raw.is_a?(::Hash)
|
|
129
170
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
baseline =
|
|
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
|
-
|
|
176
|
+
oaths[path] = baseline
|
|
136
177
|
end
|
|
137
|
-
|
|
178
|
+
LockFile.new(version: 2, oaths: oaths)
|
|
138
179
|
rescue JSON::ParserError, TypeError
|
|
139
180
|
nil
|
|
140
181
|
end
|
|
141
182
|
|
|
142
|
-
def
|
|
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
|
-
|
|
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:
|
|
170
|
-
# in document order, insertion-order keys otherwise (version,
|
|
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
|
|
173
|
-
|
|
174
|
-
lock.
|
|
175
|
-
baseline = lock.
|
|
176
|
-
|
|
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' =>
|
|
222
|
+
CanonicalJson.ordered_stringify({ 'version' => 2, 'oaths' => oaths })
|
|
182
223
|
end
|
|
183
224
|
end
|
|
184
225
|
end
|
data/lib/varar/core/execute.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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] =
|
|
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
|
|
80
|
-
#
|
|
81
|
-
#
|
|
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 =
|
|
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?
|
|
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,
|
|
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
|
-
|
|
118
|
+
if row_error || !bad.empty?
|
|
111
119
|
last_step = ex.steps.last
|
|
112
|
-
augmented = augment_stack(CellMismatchError.new(bad), last_step,
|
|
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,
|
|
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.
|
|
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}
|
|
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.
|
|
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
|
|
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
|
|
185
|
-
#
|
|
186
|
-
|
|
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
|