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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c98c97c8b2591024987fbb163ba752459045e7fd3b51cb711567909bff8d70bc
4
+ data.tar.gz: 012bb71e5e6974d00b37df2b3ab2c380c4446d9301bd887b7871ae262847b803
5
+ SHA512:
6
+ metadata.gz: 8b0b30035ea20fb0a36214099c3c18455024dea9fc8c999a072cdbfed6b16cb14313a5aa3b45cc7c87b979fbc28971698f29aa0e211335f64bd0fe5c51341bf5
7
+ data.tar.gz: 7bf02659040e7c302225da45c0385f86e12f591212e373eb70ce4d6aa2ab63d4962a4288252d668b817096836b5e696a56896cdb77e042abe7fb42c51d5f36d5
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oselvar/var/core/span'
4
+
5
+ module Oselvar
6
+ module Var
7
+ module Core
8
+ # Maps a block-text offset to its source offset. Block text is the raw
9
+ # source minus BLOCK markers only (list bullets, blockquote `>` prefixes);
10
+ # inline markup is never stripped. A paragraph/list item has a single
11
+ # entry; a blockquote one entry per quoted line.
12
+ SegmentOffset = Data.define(:text_offset, :source_offset)
13
+
14
+ Heading = Data.define(:level, :text, :span) do
15
+ def kind = 'heading'
16
+ end
17
+
18
+ Paragraph = Data.define(:text, :span, :segment_map) do
19
+ def kind = 'paragraph'
20
+ end
21
+
22
+ ListItem = Data.define(:text, :span, :segment_map, :ordered, :marker_span) do
23
+ def kind = 'list_item'
24
+ end
25
+
26
+ Blockquote = Data.define(:text, :span, :segment_map) do
27
+ def kind = 'blockquote'
28
+ end
29
+
30
+ Row = Data.define(:cells, :cell_spans, :span)
31
+
32
+ Table = Data.define(:span, :header, :rows) do
33
+ def kind = 'table'
34
+ end
35
+
36
+ Fence = Data.define(:span, :info, :body, :body_span) do
37
+ def kind = 'fence'
38
+ end
39
+
40
+ ThematicBreak = Data.define(:span) do
41
+ def kind = 'thematic_break'
42
+ end
43
+
44
+ Example = Data.define(:scope_stack, :span, :body)
45
+
46
+ VarDoc = Data.define(:path, :source, :examples, :orphan_attachments)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Oselvar
6
+ module Var
7
+ module Core
8
+ # JSON serializers byte-for-byte compatible with JS `JSON.stringify(v, null, 2)`:
9
+ # 2-space indent, LF, trailing newline, non-ASCII raw, empty containers as
10
+ # {}/[]. `canonical_stringify` recursively sorts object keys (the goldens);
11
+ # `ordered_stringify` preserves insertion order (var.lock.json).
12
+ #
13
+ # The container layout is hand-rolled because Ruby's JSON.pretty_generate
14
+ # renders empty arrays/objects as "[\n\n]". Scalar encoding is delegated to
15
+ # the stdlib, which matches JS (escapes " \ control chars, keeps non-ASCII raw).
16
+ module CanonicalJson
17
+ module_function
18
+
19
+ def canonical_stringify(value)
20
+ "#{encode(value, '', sort_keys: true)}\n"
21
+ end
22
+
23
+ def ordered_stringify(value)
24
+ "#{encode(value, '', sort_keys: false)}\n"
25
+ end
26
+
27
+ def encode(value, indent, sort_keys:)
28
+ case value
29
+ when Hash
30
+ return '{}' if value.empty?
31
+
32
+ keys = sort_keys ? value.keys.sort : value.keys
33
+ inner = "#{indent} "
34
+ items = keys.map { |key| "#{inner}#{key.to_s.to_json}: #{encode(value[key], inner, sort_keys: sort_keys)}" }
35
+ "{\n#{items.join(",\n")}\n#{indent}}"
36
+ when Array
37
+ return '[]' if value.empty?
38
+
39
+ inner = "#{indent} "
40
+ items = value.map { |element| "#{inner}#{encode(element, inner, sort_keys: sort_keys)}" }
41
+ "[\n#{items.join(",\n")}\n#{indent}]"
42
+ else
43
+ value.to_json
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oselvar
4
+ module Var
5
+ module Core
6
+ # One checked column of one header-bound row: the cell text and its span.
7
+ RowCheck = Data.define(:column, :value, :span)
8
+
9
+ # The verdict for one checked column after comparing against the table.
10
+ # expected_value/actual_value/formatted are adapter-facing, never serialized.
11
+ CellDiff = Data.define(:column, :span, :expected, :actual, :ok,
12
+ :expected_value, :actual_value, :formatted) do
13
+ def initialize(column:, span:, expected:, actual:, ok:,
14
+ expected_value: nil, actual_value: nil, formatted: false)
15
+ super
16
+ end
17
+ end
18
+
19
+ # The step returned the wrong type/shape — an author mistake, not a value diff.
20
+ class ReturnShapeError < StandardError; end
21
+
22
+ # Raised when a header-bound row's / a table's returned columns don't match.
23
+ class CellMismatchError < StandardError
24
+ attr_reader :cells
25
+
26
+ def initialize(cells)
27
+ @cells = cells
28
+ super(cells.map { |c| "#{c.column}: expected #{c.expected} but was #{c.actual}" }.join('; '))
29
+ end
30
+ end
31
+
32
+ # Pure comparison of row/table step returns against the authored cells.
33
+ # Port of cell-diff.ts.
34
+ module CellDiffs
35
+ module_function
36
+
37
+ # Display rules 2-4 of the mismatch-rendering chain (rule 1, the
38
+ # parameter type's `format`, is applied in param_diff). A string renders
39
+ # as-is, other primitives via to_s, anything else via inspect. The
40
+ # inspect fallback is port-native and deliberately outside conformance.
41
+ def render_cell_value(value)
42
+ return value if value.is_a?(String)
43
+ return value.to_s if value.nil? || value == true || value == false ||
44
+ value.is_a?(Integer) || value.is_a?(Float)
45
+
46
+ value.inspect
47
+ end
48
+
49
+ # Compare a row step's returned Hash against the row's cells. Only columns
50
+ # present on +returned+ are checked; a non-Hash return checks nothing.
51
+ def compare_row(returned, checks)
52
+ return [] unless returned.is_a?(Hash)
53
+
54
+ checks.filter_map do |check|
55
+ next unless returned.key?(check.column)
56
+
57
+ actual = render_cell_value(returned[check.column])
58
+ CellDiff.new(column: check.column, span: check.span, expected: check.value,
59
+ actual: actual, ok: actual == check.value)
60
+ end
61
+ end
62
+
63
+ # Compare a whole-table step's returned table against the input table.
64
+ # +returned+: nil (no checks), Array of Arrays (positional), or Array of
65
+ # Hashes (keyed by header). Cells compare as exact strings.
66
+ def compare_table(returned, input_table)
67
+ return [] if returned.nil?
68
+ raise ReturnShapeError, "expected a table (array of rows), got #{returned.class}" unless returned.is_a?(Array)
69
+
70
+ columns = input_table.header.cells
71
+ data_rows = input_table.rows
72
+ if returned.length != data_rows.length
73
+ raise ReturnShapeError, "expected #{data_rows.length} row(s), got #{returned.length}"
74
+ end
75
+
76
+ all_arrays = returned.all?(Array)
77
+ all_records = returned.all?(Hash)
78
+ raise ReturnShapeError, 'table rows must be all arrays or all objects' if !all_arrays && !all_records
79
+
80
+ diffs = []
81
+ data_rows.each_with_index do |row, i|
82
+ ret = returned[i]
83
+ if all_arrays && ret.length != columns.length
84
+ raise ReturnShapeError, "row #{i}: expected #{columns.length} column(s), got #{ret.length}"
85
+ end
86
+
87
+ columns.each_with_index do |column, j|
88
+ if all_arrays
89
+ actual_value = ret[j]
90
+ else
91
+ raise ReturnShapeError, "row #{i}: missing column \"#{column}\"" unless ret.key?(column)
92
+
93
+ actual_value = ret[column]
94
+ end
95
+ expected = j < row.cells.length ? row.cells[j] : ''
96
+ actual = render_cell_value(actual_value)
97
+ span = j < row.cell_spans.length ? row.cell_spans[j] : row.span
98
+ diffs << CellDiff.new(column: column, span: span, expected: expected, actual: actual,
99
+ ok: actual == expected)
100
+ end
101
+ end
102
+ diffs
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oselvar/var/core/ast'
4
+ require 'oselvar/var/core/plan'
5
+ require 'oselvar/var/core/execute'
6
+ require 'oselvar/var/core/failure_anchor'
7
+
8
+ module Oselvar
9
+ module Var
10
+ module Core
11
+ # Projections from the internal pipeline values to the camelCase wire
12
+ # dicts compared against golden/*.json. Port of conformance.ts. (var-doc
13
+ # stage; registry/plan/trace projections are added in later stages.)
14
+ module Conformance
15
+ module_function
16
+
17
+ def span_hash(span)
18
+ {
19
+ 'startOffset' => span.start_offset,
20
+ 'endOffset' => span.end_offset,
21
+ 'startLine' => span.start_line,
22
+ 'startCol' => span.start_col,
23
+ 'endLine' => span.end_line,
24
+ 'endCol' => span.end_col
25
+ }
26
+ end
27
+
28
+ def segment_hash(segment_offset)
29
+ {
30
+ 'textOffset' => segment_offset.text_offset,
31
+ 'sourceOffset' => segment_offset.source_offset
32
+ }
33
+ end
34
+
35
+ def row_hash(row)
36
+ {
37
+ 'cells' => row.cells,
38
+ 'cellSpans' => row.cell_spans.map { |cs| span_hash(cs) },
39
+ 'span' => span_hash(row.span)
40
+ }
41
+ end
42
+
43
+ def block_hash(block)
44
+ case block.kind
45
+ when 'paragraph', 'blockquote'
46
+ {
47
+ 'kind' => block.kind,
48
+ 'text' => block.text,
49
+ 'span' => span_hash(block.span),
50
+ 'segmentMap' => block.segment_map.map { |so| segment_hash(so) }
51
+ }
52
+ when 'heading'
53
+ {
54
+ 'kind' => block.kind,
55
+ 'level' => block.level,
56
+ 'text' => block.text,
57
+ 'span' => span_hash(block.span)
58
+ }
59
+ when 'list_item'
60
+ {
61
+ 'kind' => block.kind,
62
+ 'text' => block.text,
63
+ 'span' => span_hash(block.span),
64
+ 'segmentMap' => block.segment_map.map { |so| segment_hash(so) },
65
+ 'ordered' => block.ordered,
66
+ 'markerSpan' => span_hash(block.marker_span)
67
+ }
68
+ when 'table'
69
+ {
70
+ 'kind' => block.kind,
71
+ 'span' => span_hash(block.span),
72
+ 'header' => row_hash(block.header),
73
+ 'rows' => block.rows.map { |r| row_hash(r) }
74
+ }
75
+ when 'fence'
76
+ {
77
+ 'kind' => block.kind,
78
+ 'span' => span_hash(block.span),
79
+ 'info' => block.info,
80
+ 'body' => block.body,
81
+ 'bodySpan' => span_hash(block.body_span)
82
+ }
83
+ when 'thematic_break'
84
+ {
85
+ 'kind' => block.kind,
86
+ 'span' => span_hash(block.span)
87
+ }
88
+ else
89
+ raise "Unknown block kind: #{block.kind}"
90
+ end
91
+ end
92
+
93
+ def example_hash(example)
94
+ {
95
+ 'scopeStack' => example.scope_stack,
96
+ 'span' => span_hash(example.span),
97
+ 'body' => example.body.map { |b| block_hash(b) }
98
+ }
99
+ end
100
+
101
+ # Project a VarDoc to the wire dict for the var-doc artifact.
102
+ def to_var_doc_artifact(doc)
103
+ {
104
+ 'path' => doc.path,
105
+ 'examples' => doc.examples.map { |ex| example_hash(ex) },
106
+ 'orphanAttachments' => doc.orphan_attachments.map { |b| block_hash(b) }
107
+ }
108
+ end
109
+
110
+ # Parameter-type names in source order from a compiled CucumberExpression.
111
+ # The Ruby gem populates @parameter_types in source order during
112
+ # construction (it has no public reader), mirroring the TS AST walk.
113
+ def parameter_type_names(compiled)
114
+ compiled.instance_variable_get(:@parameter_types).map(&:name)
115
+ end
116
+
117
+ # Project a Registry to the wire dict for the registry artifact.
118
+ # +parameter_types+ is the custom-type list ({"name","regexp"}).
119
+ def to_registry_artifact(registry, parameter_types = [])
120
+ {
121
+ 'steps' => registry.steps.map do |s|
122
+ { 'expression' => s.expression, 'parameterTypeNames' => parameter_type_names(s.compiled) }
123
+ end,
124
+ 'parameterTypes' => parameter_types.map do |p|
125
+ { 'name' => p['name'], 'regexp' => p['regexp'] }
126
+ end
127
+ }
128
+ end
129
+
130
+ def doc_string_hash(doc_string)
131
+ {
132
+ 'content' => doc_string.content,
133
+ 'contentType' => doc_string.content_type,
134
+ 'span' => span_hash(doc_string.span)
135
+ }
136
+ end
137
+
138
+ # Project an ExecutionPlan to the wire dict for the plan artifact.
139
+ def to_plan_artifact(plan)
140
+ source = plan.var_doc.source
141
+ {
142
+ 'examples' => plan.examples.map { |ex| planned_example_hash(ex, source) },
143
+ 'diagnostics' => plan.diagnostics.map do |d|
144
+ { 'code' => d.code, 'severity' => d.severity, 'span' => span_hash(d.span) }
145
+ end
146
+ }
147
+ end
148
+
149
+ def planned_example_hash(example, source)
150
+ result = {
151
+ 'name' => example.name,
152
+ 'scopeStack' => example.scope_stack,
153
+ 'span' => span_hash(example.span),
154
+ 'expectedOutcome' => example.expected_outcome || 'pass'
155
+ }
156
+ result['expectedErrorMessage'] = example.expected_error_message if example.expected_error_message
157
+ result['steps'] = example.steps.map { |s| planned_step_hash(s, source) }
158
+ result
159
+ end
160
+
161
+ def planned_step_hash(step, source)
162
+ step_names = parameter_type_names(step.step_def.compiled)
163
+ result = {
164
+ 'text' => step.text,
165
+ 'matchSpan' => span_hash(step.match_span),
166
+ 'paramSpans' => step.param_spans.map { |s| span_hash(s) },
167
+ 'matchedExpression' => step.step_def.expression,
168
+ 'args' => step.param_spans.each_with_index.map do |s, i|
169
+ {
170
+ 'value' => Offsets.utf16_slice(source, s.start_offset, s.end_offset),
171
+ 'parameterType' => i < step_names.length ? step_names[i] : nil
172
+ }
173
+ end
174
+ }
175
+ result['dataTable'] = block_hash(step.data_table) if step.data_table
176
+ result['docString'] = doc_string_hash(step.doc_string) if step.doc_string
177
+ result
178
+ end
179
+
180
+ # Return the file stem: "path/to/foo.steps.rb" -> "foo.steps".
181
+ def file_stem(path)
182
+ File.basename(path, '.*')
183
+ end
184
+
185
+ # Project an execution error to a FailureArtifact dict. line and anchor
186
+ # are deterministic source positions (never scraped from a backtrace).
187
+ def to_failure_artifact(error, match_span)
188
+ line = match_span.start_line
189
+ anchor = span_hash(FailureAnchor.failure_anchor(error, match_span))
190
+ case error
191
+ when CellMismatchError
192
+ {
193
+ 'kind' => 'cell-mismatch', 'line' => line, 'anchor' => anchor,
194
+ 'cells' => error.cells.reject(&:ok).map do |c|
195
+ { 'column' => c.column, 'expected' => c.expected, 'actual' => c.actual, 'span' => span_hash(c.span) }
196
+ end
197
+ }
198
+ when DocStringMismatchError
199
+ {
200
+ 'kind' => 'doc-string-mismatch', 'line' => line, 'anchor' => anchor,
201
+ 'diff' => {
202
+ 'expected' => error.diff.expected,
203
+ 'actual' => error.diff.actual,
204
+ 'span' => span_hash(error.diff.span)
205
+ }
206
+ }
207
+ when ReturnShapeError
208
+ { 'kind' => 'return-shape', 'line' => line, 'anchor' => anchor }
209
+ when UnexpectedPassError
210
+ { 'kind' => 'unexpected-pass', 'line' => line, 'anchor' => anchor }
211
+ else
212
+ { 'kind' => 'thrown', 'line' => line, 'anchor' => anchor }
213
+ end
214
+ end
215
+
216
+ # Run all examples and return the four-artifact bundle. Port of runConformance.
217
+ def run_conformance(var_doc, registry, create_context, parameter_types = [])
218
+ execution = Plan.plan(var_doc, registry)
219
+ observed = Hash.new { |h, k| h[k] = [] }
220
+ observer = ->(o) { observed[o.example_index] << o }
221
+ queue = Execute.collect_examples(execution, create_context: create_context, observer: observer)
222
+
223
+ trace_examples = queue.each_with_index.map do |queued, k|
224
+ outcome = 'pass'
225
+ begin
226
+ queued.run.call
227
+ rescue StandardError
228
+ outcome = 'fail'
229
+ end
230
+
231
+ planned = execution.examples[k]
232
+ obs_list = observed[k]
233
+ steps = planned.steps.each_with_index.map do |step, i|
234
+ ordinal = i + 1
235
+ matches = obs_list.select { |x| x.ordinal == ordinal }
236
+ observation = matches.find { |m| m.outcome == 'fail' } || matches.last
237
+ step_outcome = observation ? observation.outcome : 'skipped'
238
+ step_dict = {
239
+ 'exampleName' => queued.name,
240
+ 'ordinal' => ordinal,
241
+ 'stepText' => step.text,
242
+ 'matchedExpression' => step.step_def.expression,
243
+ 'contextKey' => { 'exampleName' => queued.name,
244
+ 'stepFile' => file_stem(step.step_def.expression_source_file) },
245
+ 'outcome' => step_outcome
246
+ }
247
+ step_dict['failure'] = to_failure_artifact(observation&.error, step.match_span) if step_outcome == 'fail'
248
+ step_dict
249
+ end
250
+
251
+ { 'name' => queued.name, 'outcome' => outcome, 'steps' => steps }
252
+ end
253
+
254
+ {
255
+ var_doc: to_var_doc_artifact(var_doc),
256
+ registry: to_registry_artifact(registry, parameter_types),
257
+ plan: to_plan_artifact(execution),
258
+ trace: { 'examples' => trace_examples }
259
+ }
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oselvar
4
+ module Var
5
+ module Core
6
+ # Recursively freeze plain Hash/Array so handler code mutating state raises
7
+ # FrozenError. Other objects (class instances, primitives, nil) pass
8
+ # through. Assumes acyclic input. Port of deep-freeze.ts.
9
+ module DeepFreeze
10
+ module_function
11
+
12
+ def deep_freeze(value)
13
+ case value
14
+ when Hash
15
+ value.each_value { |v| deep_freeze(v) }
16
+ value.freeze
17
+ when Array
18
+ value.each { |v| deep_freeze(v) }
19
+ value.freeze
20
+ else
21
+ value
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oselvar
4
+ module Var
5
+ module Core
6
+ # A planning/run diagnostic on the shared rail. code is one of
7
+ # "ambiguous-match", "error-fence-without-step", "drift". Port of
8
+ # diagnostics.ts.
9
+ Diagnostic = Data.define(:code, :severity, :message, :span)
10
+ Candidate = Data.define(:expression, :source_file, :source_line)
11
+ AmbiguousInput = Data.define(:text, :span, :candidates)
12
+
13
+ module Diagnostics
14
+ module_function
15
+
16
+ def ambiguous_match(input)
17
+ lines = input.candidates.map do |c|
18
+ " '#{c.expression}' at #{c.source_file}:#{c.source_line}"
19
+ end.join("\n")
20
+ Diagnostic.new(
21
+ severity: 'error',
22
+ code: 'ambiguous-match',
23
+ message: "Ambiguous step: \"#{input.text}\"\nMatched by:\n#{lines}",
24
+ span: input.span
25
+ )
26
+ end
27
+
28
+ def drift_detected(name, span)
29
+ Diagnostic.new(
30
+ severity: 'error',
31
+ code: 'drift',
32
+ message: "This paragraph was an example and no longer matches any step (drift): \"#{name}\".\n" \
33
+ 'Fix the step so it matches again, or accept it as prose (run in update mode).',
34
+ span: span
35
+ )
36
+ end
37
+
38
+ def error_fence_without_step(span)
39
+ Diagnostic.new(
40
+ severity: 'error',
41
+ code: 'error-fence-without-step',
42
+ message: 'This `error` fence marks the example as expected-to-fail, ' \
43
+ 'but the example has no step to run.',
44
+ span: span
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oselvar/var/core/cell_diff'
4
+
5
+ module Oselvar
6
+ module Var
7
+ module Core
8
+ # A doc-string content difference: fence body span, expected, actual.
9
+ DocStringDiff = Data.define(:span, :expected, :actual)
10
+
11
+ # Raised when a doc-string step's returned string differs from the content.
12
+ class DocStringMismatchError < StandardError
13
+ attr_reader :diff
14
+
15
+ def initialize(diff)
16
+ @diff = diff
17
+ super("doc string: expected #{diff.expected.inspect} but was #{diff.actual.inspect}")
18
+ end
19
+ end
20
+
21
+ # Pure comparison of a doc-string step's return against the fence body.
22
+ # Port of doc-string-diff.ts.
23
+ module DocStringDiffs
24
+ module_function
25
+
26
+ # nil → no check; equal string → nil (pass); unequal → DocStringDiff;
27
+ # non-string → ReturnShapeError.
28
+ def compare_doc_string(returned, content, span)
29
+ return nil if returned.nil?
30
+ raise ReturnShapeError, "expected a doc string (string), got #{returned.class}" unless returned.is_a?(String)
31
+ return nil if returned == content
32
+
33
+ DocStringDiff.new(span: span, expected: content, actual: returned)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end