oselvar-var-core 0.3.2
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 +7 -0
- data/lib/oselvar/var/core/ast.rb +49 -0
- data/lib/oselvar/var/core/canonical_json.rb +49 -0
- data/lib/oselvar/var/core/cell_diff.rb +107 -0
- data/lib/oselvar/var/core/conformance.rb +264 -0
- data/lib/oselvar/var/core/deep_freeze.rb +27 -0
- data/lib/oselvar/var/core/diagnostics.rb +50 -0
- data/lib/oselvar/var/core/doc_string_diff.rb +38 -0
- data/lib/oselvar/var/core/drift.rb +187 -0
- data/lib/oselvar/var/core/execute.rb +189 -0
- data/lib/oselvar/var/core/failure_anchor.rb +30 -0
- data/lib/oselvar/var/core/hash.rb +29 -0
- data/lib/oselvar/var/core/matcher.rb +134 -0
- data/lib/oselvar/var/core/param_diff.rb +54 -0
- data/lib/oselvar/var/core/parse.rb +20 -0
- data/lib/oselvar/var/core/plan.rb +290 -0
- data/lib/oselvar/var/core/registry.rb +76 -0
- data/lib/oselvar/var/core/scanner.rb +348 -0
- data/lib/oselvar/var/core/sentences.rb +116 -0
- data/lib/oselvar/var/core/span.rb +86 -0
- data/lib/oselvar/var/core/step_role.rb +25 -0
- data/lib/oselvar/var/core/structurer.rb +86 -0
- data/lib/oselvar/var/core/table_cells.rb +46 -0
- data/lib/oselvar/var/core.rb +34 -0
- metadata +82 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'oselvar/var/core/hash'
|
|
5
|
+
require 'oselvar/var/core/diagnostics'
|
|
6
|
+
require 'oselvar/var/core/plan'
|
|
7
|
+
require 'oselvar/var/core/canonical_json'
|
|
8
|
+
|
|
9
|
+
module Oselvar
|
|
10
|
+
module Var
|
|
11
|
+
module Core
|
|
12
|
+
# One example-producing paragraph, as recorded in the baseline.
|
|
13
|
+
BaselineExample = Data.define(:name, :line)
|
|
14
|
+
# The committed baseline for one spec file.
|
|
15
|
+
SpecBaseline = Data.define(:source_hash, :examples)
|
|
16
|
+
# The whole var.lock.json: every spec keyed by its POSIX path.
|
|
17
|
+
VarLock = Data.define(:version, :specs)
|
|
18
|
+
# A paragraph the baseline says was an example and now matches no step.
|
|
19
|
+
Drift = Data.define(:name, :line, :span)
|
|
20
|
+
|
|
21
|
+
# Spec drift detection: a paragraph the committed var.lock.json baseline
|
|
22
|
+
# recorded as an example that now matches no step. Pure, byte-identical to
|
|
23
|
+
# the TS port so var.lock.json is shared across languages. Port of drift.ts.
|
|
24
|
+
#
|
|
25
|
+
# BaselineStore is a duck-typed port: #read -> String|nil, #write(contents).
|
|
26
|
+
module Drifts
|
|
27
|
+
# A paragraph may be moved anywhere and reworded up to ~half its words
|
|
28
|
+
# and still be recognized; edit it past this and it reads as remove+add,
|
|
29
|
+
# not drift. Ported byte-identically.
|
|
30
|
+
SIMILARITY_THRESHOLD = 0.5
|
|
31
|
+
TOKEN_RE = /[[:alnum:]]+/
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def within?(inner, outer)
|
|
36
|
+
inner.start_offset >= outer.start_offset && inner.end_offset <= outer.end_offset
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def live?(candidate_span, plan)
|
|
40
|
+
plan.examples.any? { |pe| within?(pe.span, candidate_span) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Lower-cased word tokens (letters/digits) — the unit of similarity.
|
|
44
|
+
def tokenize(text)
|
|
45
|
+
text.downcase.scan(TOKEN_RE).to_set
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Jaccard overlap |A∩B| / |A∪B|. 1 identical, 0 disjoint; two empty = 1.
|
|
49
|
+
def similarity(set_a, set_b)
|
|
50
|
+
return 1.0 if set_a.empty? && set_b.empty?
|
|
51
|
+
|
|
52
|
+
intersection = (set_a & set_b).size
|
|
53
|
+
union = set_a.size + set_b.size - intersection
|
|
54
|
+
union.zero? ? 0.0 : intersection.to_f / union
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The current example-producing paragraphs, in document order.
|
|
58
|
+
def live_examples(var_doc, plan)
|
|
59
|
+
var_doc.examples.filter_map do |candidate|
|
|
60
|
+
next unless live?(candidate.span, plan)
|
|
61
|
+
|
|
62
|
+
BaselineExample.new(name: Plan.derive_example_name(candidate.body), line: candidate.span.start_line)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def derive_spec_baseline(source, var_doc, plan)
|
|
67
|
+
SpecBaseline.new(source_hash: Hash32.hash_source(source), examples: live_examples(var_doc, plan))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Paragraphs the baseline recorded as examples that now match zero steps.
|
|
71
|
+
# Each re-identified by the most word-similar current paragraph at/above
|
|
72
|
+
# the threshold (exact name scores 1; ties break toward the nearest line).
|
|
73
|
+
def detect_drift(baseline, var_doc, plan)
|
|
74
|
+
return [] if baseline.nil?
|
|
75
|
+
|
|
76
|
+
candidates = var_doc.examples
|
|
77
|
+
tokens = candidates.map { |c| tokenize(Plan.derive_example_name(c.body)) }
|
|
78
|
+
live = candidates.map { |c| live?(c.span, plan) }
|
|
79
|
+
|
|
80
|
+
baseline.examples.filter_map do |b|
|
|
81
|
+
b_tokens = tokenize(b.name)
|
|
82
|
+
best_idx = -1
|
|
83
|
+
best_score = 0.0
|
|
84
|
+
candidates.each_with_index do |candidate, i|
|
|
85
|
+
score = similarity(b_tokens, tokens[i])
|
|
86
|
+
next if score < SIMILARITY_THRESHOLD
|
|
87
|
+
|
|
88
|
+
line = candidate.span.start_line
|
|
89
|
+
best_line = best_idx >= 0 ? candidates[best_idx].span.start_line : 0
|
|
90
|
+
next unless best_idx.negative? || score > best_score ||
|
|
91
|
+
(score == best_score && (line - b.line).abs < (best_line - b.line).abs)
|
|
92
|
+
|
|
93
|
+
best_idx = i
|
|
94
|
+
best_score = score
|
|
95
|
+
end
|
|
96
|
+
next if best_idx.negative?
|
|
97
|
+
next if live[best_idx]
|
|
98
|
+
|
|
99
|
+
Drift.new(name: b.name, line: candidates[best_idx].span.start_line, span: candidates[best_idx].span)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def drift_diagnostics(drifts)
|
|
104
|
+
drifts.map { |d| Diagnostics.drift_detected(d.name, d.span) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# One spec's baseline reconciliation against a BaselineStore. In update
|
|
108
|
+
# mode, accept all drift (re-record, report nothing); otherwise detect
|
|
109
|
+
# drift and rewrite the baseline only on a clean run, so an unacknowledged
|
|
110
|
+
# drift keeps its old entry (and stays red).
|
|
111
|
+
def reconcile_drift(store, spec_path, source, var_doc, plan, update: false)
|
|
112
|
+
text = store.read
|
|
113
|
+
lock = text ? parse_var_lock(text) : nil
|
|
114
|
+
baseline = lock ? lock.specs[spec_path] : nil
|
|
115
|
+
drifts = update ? [] : detect_drift(baseline, var_doc, plan)
|
|
116
|
+
if update || drifts.empty?
|
|
117
|
+
specs = lock ? lock.specs.dup : {}
|
|
118
|
+
specs[spec_path] = derive_spec_baseline(source, var_doc, plan)
|
|
119
|
+
store.write(stringify_var_lock(VarLock.new(version: 1, specs: specs)))
|
|
120
|
+
end
|
|
121
|
+
drifts
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse_var_lock(text)
|
|
125
|
+
parsed = JSON.parse(text)
|
|
126
|
+
return nil unless parsed.is_a?(::Hash) && parsed['version'] == 1
|
|
127
|
+
|
|
128
|
+
specs_raw = parsed['specs']
|
|
129
|
+
return nil unless specs_raw.is_a?(::Hash)
|
|
130
|
+
|
|
131
|
+
specs = {}
|
|
132
|
+
specs_raw.each do |path, value|
|
|
133
|
+
baseline = parse_spec_baseline(value)
|
|
134
|
+
return nil if baseline.nil?
|
|
135
|
+
|
|
136
|
+
specs[path] = baseline
|
|
137
|
+
end
|
|
138
|
+
VarLock.new(version: 1, specs: specs)
|
|
139
|
+
rescue JSON::ParserError, TypeError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_spec_baseline(value)
|
|
144
|
+
return nil unless value.is_a?(::Hash)
|
|
145
|
+
|
|
146
|
+
source_hash = value['sourceHash']
|
|
147
|
+
examples_raw = value['examples']
|
|
148
|
+
return nil unless source_hash.is_a?(String) && examples_raw.is_a?(Array)
|
|
149
|
+
|
|
150
|
+
examples = []
|
|
151
|
+
examples_raw.each do |item|
|
|
152
|
+
parsed = parse_baseline_example(item)
|
|
153
|
+
return nil if parsed.nil?
|
|
154
|
+
|
|
155
|
+
examples << parsed
|
|
156
|
+
end
|
|
157
|
+
SpecBaseline.new(source_hash: source_hash, examples: examples)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def parse_baseline_example(value)
|
|
161
|
+
return nil unless value.is_a?(::Hash)
|
|
162
|
+
|
|
163
|
+
name = value['name']
|
|
164
|
+
line = value['line']
|
|
165
|
+
return nil unless name.is_a?(String) && line.is_a?(Integer)
|
|
166
|
+
|
|
167
|
+
BaselineExample.new(name: name, line: line)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Serialize var.lock.json deterministically: spec paths sorted, examples
|
|
171
|
+
# in document order, insertion-order keys otherwise (version, specs;
|
|
172
|
+
# sourceHash, examples; name, line) — NOT canonical JSON's key sort.
|
|
173
|
+
def stringify_var_lock(lock)
|
|
174
|
+
specs = {}
|
|
175
|
+
lock.specs.keys.sort.each do |path|
|
|
176
|
+
baseline = lock.specs[path]
|
|
177
|
+
specs[path] = {
|
|
178
|
+
'sourceHash' => baseline.source_hash,
|
|
179
|
+
'examples' => baseline.examples.map { |e| { 'name' => e.name, 'line' => e.line } }
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
CanonicalJson.ordered_stringify({ 'version' => 1, 'specs' => specs })
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oselvar/var/core/span'
|
|
4
|
+
require 'oselvar/var/core/deep_freeze'
|
|
5
|
+
require 'oselvar/var/core/cell_diff'
|
|
6
|
+
require 'oselvar/var/core/doc_string_diff'
|
|
7
|
+
require 'oselvar/var/core/param_diff'
|
|
8
|
+
require 'oselvar/var/core/failure_anchor'
|
|
9
|
+
|
|
10
|
+
module Oselvar
|
|
11
|
+
module Var
|
|
12
|
+
module Core
|
|
13
|
+
# Raised when an expected-to-fail example passes unexpectedly.
|
|
14
|
+
class UnexpectedPassError < StandardError
|
|
15
|
+
def initialize(message = 'expected the example to fail, but it passed')
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Per-step outcome emitted to the optional observer.
|
|
21
|
+
StepObservation = Data.define(:example_name, :example_index, :ordinal, :step_file, :outcome, :error) do
|
|
22
|
+
def initialize(example_name:, example_index:, ordinal:, step_file:, outcome:, error: nil)
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A named, runnable example returned by collect_examples.
|
|
28
|
+
QueuedExample = Data.define(:name, :run)
|
|
29
|
+
|
|
30
|
+
# Execute an ExecutionPlan: route stimulus/sensor returns, merge immutable
|
|
31
|
+
# state, compare sensor returns via the diff helpers, invert expected
|
|
32
|
+
# failures. Handlers are user callbacks (sync). Port of execute.ts.
|
|
33
|
+
module Execute
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Collect all examples into an ordered Array of QueuedExamples.
|
|
37
|
+
def collect_examples(plan, create_context:, observer: nil, reporter: nil)
|
|
38
|
+
queue = []
|
|
39
|
+
sink = ->(name, run, _info) { queue << QueuedExample.new(name: name, run: run) }
|
|
40
|
+
execute_plan(plan, sink: sink, create_context: create_context, observer: observer, reporter: reporter)
|
|
41
|
+
queue
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def execute_plan(plan, sink:, create_context:, observer: nil, reporter: nil)
|
|
45
|
+
plan.diagnostics.each { |d| reporter.call(d) } if reporter
|
|
46
|
+
create_ctx = create_context || ->(_file) { {} }
|
|
47
|
+
var_path = plan.var_doc.path
|
|
48
|
+
|
|
49
|
+
plan.examples.each_with_index do |ex, example_index|
|
|
50
|
+
seen_lines = {}
|
|
51
|
+
ex.steps.each { |s| seen_lines[s.match_span.start_line] = true }
|
|
52
|
+
info = { lines: seen_lines.keys }
|
|
53
|
+
sink.call(ex.name, build_run(plan, ex, example_index, create_ctx, observer, var_path), info)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_run(plan, ex, example_index, create_ctx, observer, var_path)
|
|
58
|
+
lambda do
|
|
59
|
+
state_by_file = {}
|
|
60
|
+
last_return = nil
|
|
61
|
+
thrown = nil
|
|
62
|
+
|
|
63
|
+
ex.steps.each_with_index do |step, i|
|
|
64
|
+
file = step.step_def.expression_source_file
|
|
65
|
+
state_by_file[file] = DeepFreeze.deep_freeze(create_ctx.call(file)) unless state_by_file.key?(file)
|
|
66
|
+
state = state_by_file[file]
|
|
67
|
+
|
|
68
|
+
extra = []
|
|
69
|
+
if step.data_table
|
|
70
|
+
extra << ([step.data_table.header.cells] + step.data_table.rows.map(&:cells))
|
|
71
|
+
elsif step.doc_string
|
|
72
|
+
extra << step.doc_string.content
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
returned = step.step_def.handler.call(state, *step.args, *extra)
|
|
77
|
+
last_return = returned
|
|
78
|
+
case step.step_def.kind
|
|
79
|
+
when 'stimulus'
|
|
80
|
+
unless returned.nil?
|
|
81
|
+
unless returned.is_a?(Hash)
|
|
82
|
+
raise ReturnShapeError,
|
|
83
|
+
'a stimulus must return a partial state object or nothing'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
state = DeepFreeze.deep_freeze(state.merge(returned))
|
|
87
|
+
state_by_file[file] = state
|
|
88
|
+
end
|
|
89
|
+
when 'sensor'
|
|
90
|
+
compare_sensor_return(plan, ex, step, returned, extra) if ex.row_checks.nil? && !returned.nil?
|
|
91
|
+
else
|
|
92
|
+
raise ReturnShapeError, "unknown step kind: #{step.step_def.kind}"
|
|
93
|
+
end
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
augmented = augment_stack(e, step, var_path)
|
|
96
|
+
observer&.call(observation(ex, example_index, i + 1, file, 'fail', augmented))
|
|
97
|
+
thrown = augmented
|
|
98
|
+
break
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
observer&.call(observation(ex, example_index, i + 1, file, 'pass'))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Header-bound row checks (after all steps).
|
|
105
|
+
if thrown.nil? && ex.row_checks && !ex.row_checks.empty?
|
|
106
|
+
bad = CellDiffs.compare_row(last_return, ex.row_checks).reject(&:ok)
|
|
107
|
+
unless bad.empty?
|
|
108
|
+
last_step = ex.steps.last
|
|
109
|
+
augmented = augment_stack(CellMismatchError.new(bad), last_step, var_path)
|
|
110
|
+
observer&.call(observation(ex, example_index, ex.steps.length,
|
|
111
|
+
last_step.step_def.expression_source_file, 'fail', augmented))
|
|
112
|
+
thrown = augmented
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Expected-failure inversion.
|
|
117
|
+
if ex.expected_outcome == 'fail'
|
|
118
|
+
if thrown.nil?
|
|
119
|
+
error = UnexpectedPassError.new
|
|
120
|
+
last = ex.steps.last
|
|
121
|
+
raise(last ? augment_stack(error, last, var_path) : error)
|
|
122
|
+
end
|
|
123
|
+
raise thrown if ex.expected_error_message && !thrown.message.include?(ex.expected_error_message)
|
|
124
|
+
|
|
125
|
+
return # satisfied expected-failure → pass
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
raise thrown if thrown
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Sensor slot contract: zero slots + a return is a mistake; one slot IS
|
|
133
|
+
# the return; two+ is a positional array. Raises the appropriate diff error.
|
|
134
|
+
def compare_sensor_return(plan, _ex, step, returned, extra)
|
|
135
|
+
slot_count = step.args.length + extra.length
|
|
136
|
+
if slot_count.zero?
|
|
137
|
+
raise ReturnShapeError, 'this sensor has no parameters, data table or doc string — ' \
|
|
138
|
+
'nothing to compare a return value against (raise to fail, return nothing to pass)'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if slot_count == 1
|
|
142
|
+
slots = [returned]
|
|
143
|
+
else
|
|
144
|
+
unless returned.is_a?(Array)
|
|
145
|
+
raise ReturnShapeError,
|
|
146
|
+
"a sensor with #{slot_count} parameters must return a list of " \
|
|
147
|
+
"#{slot_count} values, got #{returned.class}"
|
|
148
|
+
end
|
|
149
|
+
unless returned.length == slot_count
|
|
150
|
+
raise ReturnShapeError,
|
|
151
|
+
"sensor return must have #{slot_count} element(s), got #{returned.length}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
slots = returned
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
inline_returned = slots[0...step.args.length]
|
|
158
|
+
source_texts = step.param_spans.map do |s|
|
|
159
|
+
Offsets.utf16_slice(plan.var_doc.source, s.start_offset, s.end_offset)
|
|
160
|
+
end
|
|
161
|
+
param_diffs = ParamDiff.compare_params(inline_returned, step.args, step.param_spans, source_texts,
|
|
162
|
+
step.formats).reject(&:ok)
|
|
163
|
+
raise CellMismatchError, param_diffs unless param_diffs.empty?
|
|
164
|
+
|
|
165
|
+
if step.data_table
|
|
166
|
+
bad = CellDiffs.compare_table(slots[step.args.length], step.data_table).reject(&:ok)
|
|
167
|
+
raise CellMismatchError, bad unless bad.empty?
|
|
168
|
+
elsif step.doc_string
|
|
169
|
+
diff = DocStringDiffs.compare_doc_string(slots[step.args.length], step.doc_string.content,
|
|
170
|
+
step.doc_string.span)
|
|
171
|
+
raise DocStringMismatchError, diff unless diff.nil?
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def observation(ex, example_index, ordinal, file, outcome, error = nil)
|
|
176
|
+
StepObservation.new(example_name: ex.name, example_index: example_index, ordinal: ordinal,
|
|
177
|
+
step_file: file, outcome: outcome, error: error)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# In TS this injects a synthetic `at <text> (path:line:col)` frame for
|
|
181
|
+
# editor navigation; the conformance trace derives the anchor separately
|
|
182
|
+
# via failure_anchor, so here it is a no-op that returns the error.
|
|
183
|
+
def augment_stack(error, _step, _var_path)
|
|
184
|
+
error
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oselvar/var/core/cell_diff'
|
|
4
|
+
require 'oselvar/var/core/doc_string_diff'
|
|
5
|
+
|
|
6
|
+
module Oselvar
|
|
7
|
+
module Var
|
|
8
|
+
module Core
|
|
9
|
+
# Where a failure points in the .md source: a mismatch anchors at its first
|
|
10
|
+
# failing span (cell / doc-string body), anything else at the fallback (the
|
|
11
|
+
# step's match span). The single source of truth for failure locations,
|
|
12
|
+
# pinned as failure.anchor in the conformance trace. Port of failure-anchor.ts.
|
|
13
|
+
module FailureAnchor
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def failure_anchor(error, fallback)
|
|
17
|
+
case error
|
|
18
|
+
when CellMismatchError
|
|
19
|
+
failing = error.cells.find { |c| !c.ok }
|
|
20
|
+
failing ? failing.span : fallback
|
|
21
|
+
when DocStringMismatchError
|
|
22
|
+
error.diff.span
|
|
23
|
+
else
|
|
24
|
+
fallback
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Oselvar
|
|
4
|
+
module Var
|
|
5
|
+
module Core
|
|
6
|
+
# FNV-1a (32-bit) change-detector over UTF-16 code units. Not a security
|
|
7
|
+
# hash: tiny and byte-identical to the TS/Python/JVM ports so var.lock.json
|
|
8
|
+
# fingerprints match everywhere. The "fnv1a:" prefix namespaces the algorithm.
|
|
9
|
+
# Port of hash.ts.
|
|
10
|
+
module Hash32
|
|
11
|
+
FNV_OFFSET = 0x811c9dc5
|
|
12
|
+
FNV_PRIME = 0x01000193
|
|
13
|
+
MASK = 0xffffffff
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def hash_source(source)
|
|
18
|
+
h = FNV_OFFSET
|
|
19
|
+
data = source.encode('UTF-16LE').bytes
|
|
20
|
+
(0...data.length).step(2) do |i|
|
|
21
|
+
unit = data[i] | (data[i + 1] << 8)
|
|
22
|
+
h = ((h ^ unit) * FNV_PRIME) & MASK
|
|
23
|
+
end
|
|
24
|
+
format('fnv1a:%08x', h)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oselvar/var/core/span'
|
|
4
|
+
|
|
5
|
+
module Oselvar
|
|
6
|
+
module Var
|
|
7
|
+
module Core
|
|
8
|
+
# UTF-16 start/end of one captured parameter within a sentence.
|
|
9
|
+
ParamSpan = Data.define(:start, :end)
|
|
10
|
+
# One successful expression match inside a sentence. Offsets are UTF-16.
|
|
11
|
+
Hit = Data.define(:expression, :step_def, :match_start, :match_end, :args, :param_spans, :formats) do
|
|
12
|
+
def initialize(expression:, step_def:, match_start:, match_end:, args:, param_spans:, formats: [])
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
# Two or more hits starting at the same position with equal length.
|
|
17
|
+
AmbiguityCollision = Data.define(:match_start, :match_end, :candidates)
|
|
18
|
+
# Tagged result of resolve_hits: kind "ok" (steps) or "ambiguous" (collisions).
|
|
19
|
+
ResolvedSteps = Data.define(:kind, :steps, :collisions) do
|
|
20
|
+
def initialize(kind:, steps: [], collisions: [])
|
|
21
|
+
super
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Cucumber-expression matching. Port of matcher.ts. cucumber-expressions'
|
|
26
|
+
# regexps are anchored (^...$) and its group offsets are code-point based,
|
|
27
|
+
# so we strip anchors for substring search and convert offsets to UTF-16.
|
|
28
|
+
module Matcher
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# A compiled, un-anchored pattern from the step's CucumberExpression.
|
|
32
|
+
def unanchored_pattern(step)
|
|
33
|
+
regexp = step.compiled.instance_variable_get(:@tree_regexp).regexp
|
|
34
|
+
source = regexp.source
|
|
35
|
+
source = source[1..] if source.start_with?('^')
|
|
36
|
+
source = source[0...-1] if source.end_with?('$')
|
|
37
|
+
Regexp.new(source, regexp.options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Every expression match found anywhere in +sentence+.
|
|
41
|
+
def find_hits(sentence, registry)
|
|
42
|
+
hits = []
|
|
43
|
+
registry.steps.each do |step|
|
|
44
|
+
pattern = unanchored_pattern(step)
|
|
45
|
+
pos = 0
|
|
46
|
+
while pos <= sentence.length
|
|
47
|
+
m = pattern.match(sentence, pos)
|
|
48
|
+
break if m.nil?
|
|
49
|
+
|
|
50
|
+
matched_text = m[0]
|
|
51
|
+
arguments = step.compiled.match(matched_text) || []
|
|
52
|
+
args = arguments.map { |arg| arg.value(nil) }
|
|
53
|
+
formats = arguments.map { |arg| registry.formats[arg.parameter_type.name] }
|
|
54
|
+
|
|
55
|
+
# group.start/.end are code-point offsets within matched_text; add
|
|
56
|
+
# m.begin(0) for the sentence-absolute code-point index, then to UTF-16.
|
|
57
|
+
param_spans = arguments.filter_map do |arg|
|
|
58
|
+
g = arg.group
|
|
59
|
+
next unless g.start.is_a?(Integer) && g.end.is_a?(Integer)
|
|
60
|
+
|
|
61
|
+
ParamSpan.new(
|
|
62
|
+
start: Offsets.to_utf16_offset(sentence, m.begin(0) + g.start),
|
|
63
|
+
end: Offsets.to_utf16_offset(sentence, m.begin(0) + g.end)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
hits << Hit.new(
|
|
68
|
+
expression: step.expression,
|
|
69
|
+
step_def: step,
|
|
70
|
+
match_start: Offsets.to_utf16_offset(sentence, m.begin(0)),
|
|
71
|
+
match_end: Offsets.to_utf16_offset(sentence, m.end(0)),
|
|
72
|
+
args: args,
|
|
73
|
+
param_spans: param_spans,
|
|
74
|
+
formats: formats
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
pos = matched_text.empty? ? m.begin(0) + 1 : m.end(0)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
hits
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Select the best non-overlapping hits, or report ambiguities.
|
|
84
|
+
def resolve_hits(hits)
|
|
85
|
+
return ResolvedSteps.new(kind: 'ok') if hits.empty?
|
|
86
|
+
|
|
87
|
+
# Stable sort by (match_start asc, length desc); the original index
|
|
88
|
+
# breaks ties so equal-key order follows registration order (Ruby's
|
|
89
|
+
# sort_by is not stable, Python's sorted is).
|
|
90
|
+
sorted = hits.each_with_index.sort_by do |h, i|
|
|
91
|
+
[h.match_start, -(h.match_end - h.match_start), i]
|
|
92
|
+
end.map(&:first)
|
|
93
|
+
|
|
94
|
+
collisions = []
|
|
95
|
+
i = 0
|
|
96
|
+
while i < sorted.length
|
|
97
|
+
here = sorted[i]
|
|
98
|
+
here_len = here.match_end - here.match_start
|
|
99
|
+
tied = [here]
|
|
100
|
+
j = i + 1
|
|
101
|
+
while j < sorted.length
|
|
102
|
+
candidate = sorted[j]
|
|
103
|
+
if candidate.match_start == here.match_start &&
|
|
104
|
+
candidate.match_end - candidate.match_start == here_len
|
|
105
|
+
tied << candidate
|
|
106
|
+
j += 1
|
|
107
|
+
else
|
|
108
|
+
break
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
if tied.length > 1
|
|
112
|
+
collisions << AmbiguityCollision.new(
|
|
113
|
+
match_start: here.match_start, match_end: here.match_end, candidates: tied
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
i = j
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return ResolvedSteps.new(kind: 'ambiguous', collisions: collisions) unless collisions.empty?
|
|
120
|
+
|
|
121
|
+
steps = []
|
|
122
|
+
cursor = -1
|
|
123
|
+
sorted.each do |hit|
|
|
124
|
+
next if hit.match_start < cursor
|
|
125
|
+
|
|
126
|
+
steps << hit
|
|
127
|
+
cursor = hit.match_end
|
|
128
|
+
end
|
|
129
|
+
ResolvedSteps.new(kind: 'ok', steps: steps)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oselvar/var/core/cell_diff'
|
|
4
|
+
|
|
5
|
+
module Oselvar
|
|
6
|
+
module Var
|
|
7
|
+
module Core
|
|
8
|
+
# Compare a sensor's returned inline actuals against captured document
|
|
9
|
+
# values. Port of param-diff.ts.
|
|
10
|
+
module ParamDiff
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Render one side of a parameter diff as [text, via_format]. The
|
|
14
|
+
# parameter type's format wins (document notation), else the shared
|
|
15
|
+
# string/primitive/inspect chain; a raising formatter falls through.
|
|
16
|
+
def render_param_value(value, format)
|
|
17
|
+
if format
|
|
18
|
+
begin
|
|
19
|
+
return [format.call(value), true]
|
|
20
|
+
rescue StandardError
|
|
21
|
+
# fall through to the native rendering
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
[CellDiffs.render_cell_value(value), false]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Compare returned actuals against expected document values. Arrays align
|
|
28
|
+
# 1:1; structural equality (==) compares by value across references.
|
|
29
|
+
def compare_params(returned, expected, param_spans, source_texts, formats = nil)
|
|
30
|
+
expected.each_index.map do |i|
|
|
31
|
+
ok = returned[i] == expected[i]
|
|
32
|
+
format = formats && i < formats.length ? formats[i] : nil
|
|
33
|
+
actual_text, via_format = render_param_value(returned[i], format)
|
|
34
|
+
expected_text = if i < source_texts.length
|
|
35
|
+
source_texts[i]
|
|
36
|
+
else
|
|
37
|
+
render_param_value(expected[i], format)[0]
|
|
38
|
+
end
|
|
39
|
+
CellDiff.new(
|
|
40
|
+
column: "arg #{i + 1}",
|
|
41
|
+
span: param_spans[i],
|
|
42
|
+
expected: expected_text,
|
|
43
|
+
actual: actual_text,
|
|
44
|
+
ok: ok,
|
|
45
|
+
expected_value: expected[i],
|
|
46
|
+
actual_value: returned[i],
|
|
47
|
+
formatted: via_format
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oselvar/var/core/scanner'
|
|
4
|
+
require 'oselvar/var/core/structurer'
|
|
5
|
+
|
|
6
|
+
module Oselvar
|
|
7
|
+
module Var
|
|
8
|
+
module Core
|
|
9
|
+
# Parse +source+ into a VarDoc: scan blocks, then group into Examples.
|
|
10
|
+
# Port of parse.ts.
|
|
11
|
+
module Parse
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def parse(path, source, plugins = [])
|
|
15
|
+
Structurer.structure(path, source, Scanner.scan(source, plugins))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|