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