varar-core 0.7.0 → 0.8.1
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 +4 -1
- data/lib/varar/core/canonical_json.rb +15 -15
- data/lib/varar/core/conformance.rb +15 -10
- data/lib/varar/core/diagnostics.rb +57 -2
- data/lib/varar/core/drift.rb +71 -38
- data/lib/varar/core/execute.rb +17 -10
- data/lib/varar/core/failure.rb +56 -0
- data/lib/varar/core/failure_anchor.rb +37 -0
- data/lib/varar/core/parse.rb +1 -1
- data/lib/varar/core/plan.rb +144 -31
- data/lib/varar/core/reference.rb +142 -0
- data/lib/varar/core/registry.rb +1 -1
- data/lib/varar/core/result.rb +94 -0
- data/lib/varar/core/structurer.rb +5 -2
- data/lib/varar/core.rb +3 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 76ca7dbd630ae0e9925114d31967993e9a0f366a83844da58778afffaa1bf555
|
|
4
|
+
data.tar.gz: e37e4f44a2469a038ba6c733b6098249501c6c8bcdbc9e6db84a4d0219872bed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5e9e258f4c033d489f529ddccb3d460afbae2c064ac58de82bed5f25b166ecc295d3c3537ad257d1a88b06784f8f21e3df26d808c5d36f521c66c3a147af9881
|
|
7
|
+
data.tar.gz: 9dc4d46d4052654752e5736b1703236e517e95412c193bd7851d1c37d6c2a88e4eea6f77c9aee7617272f769a796eb608d85111d243125b1209227ff95320454
|
data/lib/varar/core/ast.rb
CHANGED
|
@@ -46,6 +46,9 @@ module Varar
|
|
|
46
46
|
# one example. See ADR 0012.
|
|
47
47
|
Example = Data.define(:scope_stack, :span, :body, :preceded_by_delimiter)
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
# +headings+ is every heading in the document in source order — the same
|
|
50
|
+
# Heading values the scanner produced. The planner uses it to tell whether
|
|
51
|
+
# a reference anchor names one section or several (ADR 0016).
|
|
52
|
+
Doc = Data.define(:path, :source, :examples, :orphan_attachments, :headings)
|
|
50
53
|
end
|
|
51
54
|
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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'varar/core/ast'
|
|
4
4
|
require 'varar/core/plan'
|
|
5
|
+
require 'varar/core/reference'
|
|
5
6
|
require 'varar/core/execute'
|
|
6
7
|
require 'varar/core/failure_anchor'
|
|
7
8
|
|
|
@@ -98,12 +99,13 @@ module Varar
|
|
|
98
99
|
}
|
|
99
100
|
end
|
|
100
101
|
|
|
101
|
-
# Project a
|
|
102
|
-
def
|
|
102
|
+
# Project a Doc to the wire dict for the var-doc artifact.
|
|
103
|
+
def to_doc_artifact(doc)
|
|
103
104
|
{
|
|
104
105
|
'path' => doc.path,
|
|
105
106
|
'examples' => doc.examples.map { |ex| example_hash(ex) },
|
|
106
|
-
'orphanAttachments' => doc.orphan_attachments.map { |b| block_hash(b) }
|
|
107
|
+
'orphanAttachments' => doc.orphan_attachments.map { |b| block_hash(b) },
|
|
108
|
+
'headings' => doc.headings.map { |h| block_hash(h) }
|
|
107
109
|
}
|
|
108
110
|
end
|
|
109
111
|
|
|
@@ -137,7 +139,7 @@ module Varar
|
|
|
137
139
|
|
|
138
140
|
# Project an ExecutionPlan to the wire dict for the plan artifact.
|
|
139
141
|
def to_plan_artifact(plan)
|
|
140
|
-
source = plan.
|
|
142
|
+
source = plan.doc.source
|
|
141
143
|
{
|
|
142
144
|
'examples' => plan.examples.map { |ex| planned_example_hash(ex, source) },
|
|
143
145
|
'diagnostics' => plan.diagnostics.map do |d|
|
|
@@ -158,20 +160,23 @@ module Varar
|
|
|
158
160
|
result
|
|
159
161
|
end
|
|
160
162
|
|
|
161
|
-
def planned_step_hash(step,
|
|
163
|
+
def planned_step_hash(step, _source)
|
|
162
164
|
step_names = parameter_type_names(step.step_def.compiled)
|
|
163
165
|
result = {
|
|
164
166
|
'text' => step.text,
|
|
165
167
|
'matchSpan' => span_hash(step.match_span),
|
|
166
168
|
'paramSpans' => step.param_spans.map { |s| span_hash(s) },
|
|
167
169
|
'matchedExpression' => step.step_def.expression,
|
|
168
|
-
'args' => step.
|
|
170
|
+
'args' => step.param_texts.each_with_index.map do |value, i|
|
|
169
171
|
{
|
|
170
|
-
'value' =>
|
|
172
|
+
'value' => value,
|
|
171
173
|
'parameterType' => i < step_names.length ? step_names[i] : nil
|
|
172
174
|
}
|
|
173
175
|
end
|
|
174
176
|
}
|
|
177
|
+
# Present only on a step a reference block spliced in from another oath
|
|
178
|
+
# (ADR 0016): the document its spans belong to.
|
|
179
|
+
result['docPath'] = step.doc_path if step.doc_path
|
|
175
180
|
result['dataTable'] = block_hash(step.data_table) if step.data_table
|
|
176
181
|
result['docString'] = doc_string_hash(step.doc_string) if step.doc_string
|
|
177
182
|
result
|
|
@@ -206,8 +211,8 @@ module Varar
|
|
|
206
211
|
end
|
|
207
212
|
|
|
208
213
|
# Run all examples and return the four-artifact bundle. Port of runConformance.
|
|
209
|
-
def run_conformance(
|
|
210
|
-
execution = Plan.plan(
|
|
214
|
+
def run_conformance(doc, registry, create_context, parameter_types = [], workspace = nil)
|
|
215
|
+
execution = Plan.plan(doc, registry, workspace || Reference.empty_workspace)
|
|
211
216
|
observed = Hash.new { |h, k| h[k] = [] }
|
|
212
217
|
observer = ->(o) { observed[o.example_index] << o }
|
|
213
218
|
queue = Execute.collect_examples(execution, create_context: create_context, observer: observer)
|
|
@@ -244,7 +249,7 @@ module Varar
|
|
|
244
249
|
end
|
|
245
250
|
|
|
246
251
|
{
|
|
247
|
-
|
|
252
|
+
doc: to_doc_artifact(doc),
|
|
248
253
|
registry: to_registry_artifact(registry, parameter_types),
|
|
249
254
|
plan: to_plan_artifact(execution),
|
|
250
255
|
trace: { 'examples' => trace_examples }
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
module Varar
|
|
4
4
|
module Core
|
|
5
5
|
# A planning/run diagnostic on the shared rail. code is one of
|
|
6
|
-
# "ambiguous-match", "error-fence-without-step", "drift"
|
|
7
|
-
#
|
|
6
|
+
# "ambiguous-match", "error-fence-without-step", "drift",
|
|
7
|
+
# "reference-not-found", "reference-empty", "reference-cycle",
|
|
8
|
+
# "ambiguous-anchor". Port of diagnostics.ts.
|
|
8
9
|
Diagnostic = Data.define(:code, :severity, :message, :span)
|
|
9
10
|
Candidate = Data.define(:expression, :source_file, :source_line)
|
|
10
11
|
AmbiguousInput = Data.define(:text, :span, :candidates)
|
|
@@ -43,6 +44,60 @@ module Varar
|
|
|
43
44
|
span: span
|
|
44
45
|
)
|
|
45
46
|
end
|
|
47
|
+
|
|
48
|
+
# A reference block (ADR 0016) points at an oath the workspace does not
|
|
49
|
+
# hold. Never prose: a link-only block that resolves to nothing has no
|
|
50
|
+
# other reading, so it fails the run rather than degrading silently.
|
|
51
|
+
def reference_not_found(text, path, span)
|
|
52
|
+
Diagnostic.new(
|
|
53
|
+
severity: 'error',
|
|
54
|
+
code: 'reference-not-found',
|
|
55
|
+
message: %(Reference to "#{text}" points at "#{path}", which is not an oath in this ) +
|
|
56
|
+
"workspace.\nCheck the path, and that the file is matched by the `docs` globs " \
|
|
57
|
+
'in varar.config.json.',
|
|
58
|
+
span: span
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The referenced document exists but the section contributes no steps — a
|
|
63
|
+
# mistyped anchor, or a section that is pure prose.
|
|
64
|
+
def reference_empty(text, path, slug, span)
|
|
65
|
+
where = slug.empty? ? path : "#{path}##{slug}"
|
|
66
|
+
Diagnostic.new(
|
|
67
|
+
severity: 'error',
|
|
68
|
+
code: 'reference-empty',
|
|
69
|
+
message: %(Reference to "#{text}" resolves to "#{where}", which contributes no steps.\n) +
|
|
70
|
+
'Check the heading the anchor names, and that its section contains a matching ' \
|
|
71
|
+
'paragraph.',
|
|
72
|
+
span: span
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The anchor names more than one heading in the referenced document:
|
|
77
|
+
# GitHub would disambiguate with a numeric suffix, but a reference that
|
|
78
|
+
# could mean either section is an error, not a guess (ADR 0016).
|
|
79
|
+
def ambiguous_anchor(text, path, slug, heading_lines, span)
|
|
80
|
+
Diagnostic.new(
|
|
81
|
+
severity: 'error',
|
|
82
|
+
code: 'ambiguous-anchor',
|
|
83
|
+
message: "Reference to \"#{text}\" is ambiguous: \"#{path}\" has #{heading_lines.length} headings " \
|
|
84
|
+
"with the anchor \"##{slug}\" (lines #{heading_lines.join(', ')}).\n" \
|
|
85
|
+
'Rename the headings so each has an anchor of its own.',
|
|
86
|
+
span: span
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# References may nest to any depth (depth is a style question, not a
|
|
91
|
+
# rule), so a chain that reaches a section already on it must be reported
|
|
92
|
+
# rather than recursed into.
|
|
93
|
+
def reference_cycle(chain, span)
|
|
94
|
+
Diagnostic.new(
|
|
95
|
+
severity: 'error',
|
|
96
|
+
code: 'reference-cycle',
|
|
97
|
+
message: "Reference cycle: #{chain.join(' → ')}.",
|
|
98
|
+
span: span
|
|
99
|
+
)
|
|
100
|
+
end
|
|
46
101
|
end
|
|
47
102
|
end
|
|
48
103
|
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
|
#
|
|
@@ -62,25 +62,25 @@ module Varar
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
# The current example-producing paragraphs, in document order.
|
|
65
|
-
def live_examples(
|
|
66
|
-
|
|
65
|
+
def live_examples(doc, plan)
|
|
66
|
+
doc.examples.filter_map do |candidate|
|
|
67
67
|
next unless live?(candidate.span, plan)
|
|
68
68
|
|
|
69
69
|
BaselineExample.new(name: Plan.derive_example_name(candidate.body), line: candidate.span.start_line)
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def
|
|
74
|
-
|
|
73
|
+
def derive_oath_baseline(source, doc, plan)
|
|
74
|
+
OathBaseline.new(source_hash: Hash32.hash_source(source), examples: live_examples(doc, plan))
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
# Paragraphs the baseline recorded as examples that now match zero steps.
|
|
78
78
|
# Each re-identified by the most word-similar current paragraph at/above
|
|
79
79
|
# the threshold (exact name scores 1; ties break toward the nearest line).
|
|
80
|
-
def detect_drift(baseline,
|
|
80
|
+
def detect_drift(baseline, doc, plan)
|
|
81
81
|
return [] if baseline.nil?
|
|
82
82
|
|
|
83
|
-
candidates =
|
|
83
|
+
candidates = doc.examples
|
|
84
84
|
tokens = candidates.map { |c| tokenize(Plan.derive_example_name(c.body)) }
|
|
85
85
|
live = candidates.map { |c| live?(c.span, plan) }
|
|
86
86
|
|
|
@@ -111,43 +111,76 @@ module Varar
|
|
|
111
111
|
drifts.map { |d| Diagnostics.drift_detected(d.name, d.span) }
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
-
# One
|
|
114
|
+
# One oath's baseline reconciliation against a BaselineStore. In update
|
|
115
115
|
# mode, accept all drift (re-record, report nothing); otherwise detect
|
|
116
116
|
# drift and rewrite the baseline only on a clean run, so an unacknowledged
|
|
117
117
|
# drift keeps its old entry (and stays red).
|
|
118
|
-
def reconcile_drift(store,
|
|
118
|
+
def reconcile_drift(store, oath_path, source, doc, plan, update: false)
|
|
119
119
|
text = store.read
|
|
120
|
-
lock = text ?
|
|
121
|
-
baseline = lock ? lock.
|
|
122
|
-
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)
|
|
123
123
|
if update || drifts.empty?
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
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)))
|
|
127
127
|
end
|
|
128
128
|
drifts
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
-
|
|
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)
|
|
132
165
|
parsed = JSON.parse(text)
|
|
133
|
-
return nil unless parsed.is_a?(::Hash) && parsed['version'] ==
|
|
166
|
+
return nil unless parsed.is_a?(::Hash) && parsed['version'] == 2
|
|
134
167
|
|
|
135
|
-
|
|
136
|
-
return nil unless
|
|
168
|
+
oaths_raw = parsed['oaths']
|
|
169
|
+
return nil unless oaths_raw.is_a?(::Hash)
|
|
137
170
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
baseline =
|
|
171
|
+
oaths = {}
|
|
172
|
+
oaths_raw.each do |path, value|
|
|
173
|
+
baseline = parse_oath_baseline(value)
|
|
141
174
|
return nil if baseline.nil?
|
|
142
175
|
|
|
143
|
-
|
|
176
|
+
oaths[path] = baseline
|
|
144
177
|
end
|
|
145
|
-
|
|
178
|
+
LockFile.new(version: 2, oaths: oaths)
|
|
146
179
|
rescue JSON::ParserError, TypeError
|
|
147
180
|
nil
|
|
148
181
|
end
|
|
149
182
|
|
|
150
|
-
def
|
|
183
|
+
def parse_oath_baseline(value)
|
|
151
184
|
return nil unless value.is_a?(::Hash)
|
|
152
185
|
|
|
153
186
|
source_hash = value['sourceHash']
|
|
@@ -161,7 +194,7 @@ module Varar
|
|
|
161
194
|
|
|
162
195
|
examples << parsed
|
|
163
196
|
end
|
|
164
|
-
|
|
197
|
+
OathBaseline.new(source_hash: source_hash, examples: examples)
|
|
165
198
|
end
|
|
166
199
|
|
|
167
200
|
def parse_baseline_example(value)
|
|
@@ -174,19 +207,19 @@ module Varar
|
|
|
174
207
|
BaselineExample.new(name: name, line: line)
|
|
175
208
|
end
|
|
176
209
|
|
|
177
|
-
# Serialize varar.lock.json deterministically:
|
|
178
|
-
# 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;
|
|
179
212
|
# sourceHash, examples; name, line) — NOT canonical JSON's key sort.
|
|
180
|
-
def
|
|
181
|
-
|
|
182
|
-
lock.
|
|
183
|
-
baseline = lock.
|
|
184
|
-
|
|
213
|
+
def stringify_lock_file(lock)
|
|
214
|
+
oaths = {}
|
|
215
|
+
lock.oaths.keys.sort.each do |path|
|
|
216
|
+
baseline = lock.oaths[path]
|
|
217
|
+
oaths[path] = {
|
|
185
218
|
'sourceHash' => baseline.source_hash,
|
|
186
219
|
'examples' => baseline.examples.map { |e| { 'name' => e.name, 'line' => e.line } }
|
|
187
220
|
}
|
|
188
221
|
end
|
|
189
|
-
CanonicalJson.ordered_stringify({ 'version' =>
|
|
222
|
+
CanonicalJson.ordered_stringify({ 'version' => 2, 'oaths' => oaths })
|
|
190
223
|
end
|
|
191
224
|
end
|
|
192
225
|
end
|
data/lib/varar/core/execute.rb
CHANGED
|
@@ -42,17 +42,17 @@ module Varar
|
|
|
42
42
|
def execute_plan(plan, sink:, create_context:, observer: nil, reporter: nil)
|
|
43
43
|
plan.diagnostics.each { |d| reporter.call(d) } if reporter
|
|
44
44
|
create_ctx = create_context || ->(_file) { {} }
|
|
45
|
-
|
|
45
|
+
oath_path = plan.doc.path
|
|
46
46
|
|
|
47
47
|
plan.examples.each_with_index do |ex, example_index|
|
|
48
48
|
seen_lines = {}
|
|
49
49
|
ex.steps.each { |s| seen_lines[s.match_span.start_line] = true }
|
|
50
50
|
info = { lines: seen_lines.keys }
|
|
51
|
-
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)
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
def build_run(plan, ex, example_index, create_ctx, observer,
|
|
55
|
+
def build_run(plan, ex, example_index, create_ctx, observer, oath_path)
|
|
56
56
|
lambda do
|
|
57
57
|
state_by_file = {}
|
|
58
58
|
last_return = nil
|
|
@@ -97,7 +97,7 @@ module Varar
|
|
|
97
97
|
raise ReturnShapeError, "unknown step kind: #{step.step_def.kind}"
|
|
98
98
|
end
|
|
99
99
|
rescue StandardError => e
|
|
100
|
-
augmented = augment_stack(e, step,
|
|
100
|
+
augmented = augment_stack(e, step, oath_path)
|
|
101
101
|
observer&.call(observation(ex, example_index, i + 1, file, 'fail', augmented))
|
|
102
102
|
thrown = augmented
|
|
103
103
|
break
|
|
@@ -117,7 +117,7 @@ module Varar
|
|
|
117
117
|
bad = CellDiffs.compare_row(last_return, ex.row_checks).reject(&:ok)
|
|
118
118
|
if row_error || !bad.empty?
|
|
119
119
|
last_step = ex.steps.last
|
|
120
|
-
augmented = augment_stack(row_error || CellMismatchError.new(bad), last_step,
|
|
120
|
+
augmented = augment_stack(row_error || CellMismatchError.new(bad), last_step, oath_path)
|
|
121
121
|
observer&.call(observation(ex, example_index, ex.steps.length,
|
|
122
122
|
last_step.step_def.expression_source_file, 'fail', augmented))
|
|
123
123
|
thrown = augmented
|
|
@@ -129,7 +129,7 @@ module Varar
|
|
|
129
129
|
if thrown.nil?
|
|
130
130
|
error = UnexpectedPassError.new
|
|
131
131
|
last = ex.steps.last
|
|
132
|
-
raise(last ? augment_stack(error, last,
|
|
132
|
+
raise(last ? augment_stack(error, last, oath_path) : error)
|
|
133
133
|
end
|
|
134
134
|
raise thrown if ex.expected_error_message && !thrown.message.include?(ex.expected_error_message)
|
|
135
135
|
|
|
@@ -177,7 +177,7 @@ module Varar
|
|
|
177
177
|
|
|
178
178
|
inline_returned = slots[0...step.args.length]
|
|
179
179
|
source_texts = step.param_spans.map do |s|
|
|
180
|
-
Offsets.utf16_slice(plan.
|
|
180
|
+
Offsets.utf16_slice(plan.doc.source, s.start_offset, s.end_offset)
|
|
181
181
|
end
|
|
182
182
|
param_diffs = ParamDiff.compare_params(inline_returned, step.args, step.param_spans, source_texts,
|
|
183
183
|
step.formats).reject(&:ok)
|
|
@@ -199,9 +199,16 @@ module Varar
|
|
|
199
199
|
end
|
|
200
200
|
|
|
201
201
|
# In TS this injects a synthetic `at <text> (path:line:col)` frame for
|
|
202
|
-
# editor navigation
|
|
203
|
-
#
|
|
204
|
-
|
|
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))
|
|
208
|
+
# A step spliced in by a reference block has spans in the document it
|
|
209
|
+
# was WRITTEN in, so the payload must name that file — otherwise a
|
|
210
|
+
# renderer points at the running oath's line N, some other sentence.
|
|
211
|
+
FailureAnchor.attach_doc_path(error, step.doc_path) if step.doc_path
|
|
205
212
|
error
|
|
206
213
|
end
|
|
207
214
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
doc_path: FailureAnchor.attached_doc_path(error)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Every mismatched cell — table, header-bound row, inline capture or doc
|
|
38
|
+
# string. nil (not an empty array) when the error is not a mismatch, so
|
|
39
|
+
# the key stays absent in the serialized payload.
|
|
40
|
+
def failing_cells(error)
|
|
41
|
+
return nil unless error.is_a?(CellMismatchError)
|
|
42
|
+
|
|
43
|
+
failing = error.cells.reject(&:ok).map do |c|
|
|
44
|
+
CellFailure.new(from: c.span.start_offset, to: c.span.end_offset, actual: c.actual)
|
|
45
|
+
end
|
|
46
|
+
failing.empty? ? nil : failing
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Display-only: the message plus Ruby's own backtrace. Runtime-shaped by
|
|
50
|
+
# design (ADR 0014) — no consumer parses it.
|
|
51
|
+
def render_stack(error)
|
|
52
|
+
([error.message] + Array(error.backtrace)).join("\n")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -12,6 +12,14 @@ module Varar
|
|
|
12
12
|
module FailureAnchor
|
|
13
13
|
module_function
|
|
14
14
|
|
|
15
|
+
# The anchor travels with the raised error, from the executor (which knows
|
|
16
|
+
# the step) to Failures.to_failure (which only sees the error) — the same
|
|
17
|
+
# job TS does with a global symbol on the Error. An instance variable on
|
|
18
|
+
# the exception, so it never shows up in `inspect` output the way an
|
|
19
|
+
# extra attribute would.
|
|
20
|
+
ANCHOR_IVAR = :@varar_failure_anchor
|
|
21
|
+
DOC_PATH_IVAR = :@varar_failure_doc_path
|
|
22
|
+
|
|
15
23
|
def failure_anchor(error, fallback)
|
|
16
24
|
case error
|
|
17
25
|
when CellMismatchError
|
|
@@ -21,6 +29,35 @@ module Varar
|
|
|
21
29
|
fallback
|
|
22
30
|
end
|
|
23
31
|
end
|
|
32
|
+
|
|
33
|
+
# Record on the error itself where the failure points.
|
|
34
|
+
def attach_anchor(error, anchor)
|
|
35
|
+
error.instance_variable_set(ANCHOR_IVAR, anchor) if error.respond_to?(:instance_variable_set)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The anchor the executor attached, or nil if there is none — then a
|
|
39
|
+
# renderer only has the failing line to go on.
|
|
40
|
+
def attached_anchor(error)
|
|
41
|
+
return nil unless error.respond_to?(:instance_variable_get)
|
|
42
|
+
|
|
43
|
+
error.instance_variable_get(ANCHOR_IVAR)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The document the anchor's offsets belong to, for a step a reference
|
|
47
|
+
# block spliced in from another oath (ADR 0016). Travels the same way and
|
|
48
|
+
# for the same reason as the anchor: the executor knows the step, and
|
|
49
|
+
# whoever builds the failure payload sees only the error.
|
|
50
|
+
def attach_doc_path(error, doc_path)
|
|
51
|
+
return unless error.respond_to?(:instance_variable_set)
|
|
52
|
+
|
|
53
|
+
error.instance_variable_set(DOC_PATH_IVAR, doc_path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def attached_doc_path(error)
|
|
57
|
+
return nil unless error.respond_to?(:instance_variable_get)
|
|
58
|
+
|
|
59
|
+
error.instance_variable_get(DOC_PATH_IVAR)
|
|
60
|
+
end
|
|
24
61
|
end
|
|
25
62
|
end
|
|
26
63
|
end
|
data/lib/varar/core/parse.rb
CHANGED
data/lib/varar/core/plan.rb
CHANGED
|
@@ -5,16 +5,22 @@ require 'varar/core/ast'
|
|
|
5
5
|
require 'varar/core/cell_diff'
|
|
6
6
|
require 'varar/core/diagnostics'
|
|
7
7
|
require 'varar/core/matcher'
|
|
8
|
+
require 'varar/core/reference'
|
|
8
9
|
require 'varar/core/sentences'
|
|
9
10
|
|
|
10
11
|
module Varar
|
|
11
12
|
module Core
|
|
12
13
|
DocString = Data.define(:content, :content_type, :span)
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
# param_texts: the notation each parameter matched, sliced at plan time from
|
|
16
|
+
# the document the step was WRITTEN in — consumers must use it rather than
|
|
17
|
+
# slicing the running oath's source, because a step a reference block
|
|
18
|
+
# spliced in (ADR 0016) has spans in a different document.
|
|
19
|
+
# doc_path: set only on such a spliced step — the document its spans belong to.
|
|
20
|
+
PlannedStep = Data.define(:text, :match_span, :param_spans, :param_texts, :step_def, :args, :formats,
|
|
21
|
+
:data_table, :doc_string, :doc_path) do
|
|
22
|
+
def initialize(text:, match_span:, param_spans:, step_def:, args:, param_texts: [], formats: [],
|
|
23
|
+
data_table: nil, doc_string: nil, doc_path: nil)
|
|
18
24
|
super
|
|
19
25
|
end
|
|
20
26
|
end
|
|
@@ -29,9 +35,9 @@ module Varar
|
|
|
29
35
|
end
|
|
30
36
|
end
|
|
31
37
|
|
|
32
|
-
ExecutionPlan = Data.define(:
|
|
38
|
+
ExecutionPlan = Data.define(:doc, :examples, :diagnostics)
|
|
33
39
|
|
|
34
|
-
# Produce an ExecutionPlan from a
|
|
40
|
+
# Produce an ExecutionPlan from a Doc + Registry: match step expressions
|
|
35
41
|
# against every text block, attach trailing tables/fences, detect
|
|
36
42
|
# header-bound tables, and collect diagnostics. Port of plan.ts.
|
|
37
43
|
module Plan
|
|
@@ -42,21 +48,40 @@ module Varar
|
|
|
42
48
|
# header-bound table (standalone rows) or a step-bearing candidate the
|
|
43
49
|
# grouping pass may merge into an open example.
|
|
44
50
|
HeaderBoundUnit = Data.define(:rows)
|
|
51
|
+
# A reference block: its whole text is a link to an oath section, whose
|
|
52
|
+
# steps are spliced in here (ADR 0016). Never prose, so it does not close
|
|
53
|
+
# the open example. Its span and scope stack are the referring
|
|
54
|
+
# document's: the example it opens lives here, not in the section.
|
|
55
|
+
ReferenceUnit = Data.define(:reference, :preceded_by_delimiter, :span, :scope_stack)
|
|
45
56
|
StepsUnit = Data.define(:matched, :preceded_by_delimiter, :name, :scope_stack, :span, :steps,
|
|
46
57
|
:expected_outcome, :expected_error_message)
|
|
47
58
|
|
|
48
59
|
# An open, merging example being built up across adjacent matching
|
|
49
60
|
# candidates in Phase 2.
|
|
61
|
+
# name_from_reference: true while the name came from a spliced
|
|
62
|
+
# (referenced) paragraph and is waiting to be replaced by the example's
|
|
63
|
+
# own first matching paragraph.
|
|
50
64
|
MergedExample = Struct.new(:name, :scope_stack, :start_offset, :end_offset, :steps,
|
|
51
|
-
:expected_outcome, :expected_error_message)
|
|
65
|
+
:expected_outcome, :expected_error_message, :name_from_reference)
|
|
52
66
|
|
|
53
67
|
module_function
|
|
54
68
|
|
|
55
|
-
def plan(
|
|
69
|
+
def plan(doc, registry, workspace)
|
|
56
70
|
diagnostics = []
|
|
57
71
|
|
|
72
|
+
# A section another oath references stops being a standalone example:
|
|
73
|
+
# it runs where it is referenced, not here (ADR 0016).
|
|
74
|
+
whole_file = Reference.section_key(doc.path, '')
|
|
75
|
+
consumed = lambda do |ex|
|
|
76
|
+
workspace.referenced.include?(whole_file) ||
|
|
77
|
+
ex.scope_stack.any? do |h|
|
|
78
|
+
workspace.referenced.include?(Reference.section_key(doc.path, Reference.slugify(h)))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
58
82
|
# Phase 1: plan each candidate paragraph independently into a "unit".
|
|
59
|
-
units =
|
|
83
|
+
units = doc.examples.reject { |ex| consumed.call(ex) }
|
|
84
|
+
.map { |ex| plan_candidate(ex, doc, registry, diagnostics) }
|
|
60
85
|
|
|
61
86
|
# Phase 2: group adjacent candidates into examples. A matching candidate
|
|
62
87
|
# continues the open example when no delimiter (heading / `---`) precedes
|
|
@@ -66,7 +91,7 @@ module Varar
|
|
|
66
91
|
examples = []
|
|
67
92
|
open = nil
|
|
68
93
|
flush = lambda do
|
|
69
|
-
examples << finish_merged(open,
|
|
94
|
+
examples << finish_merged(open, doc.source) if open
|
|
70
95
|
open = nil
|
|
71
96
|
end
|
|
72
97
|
units.each do |unit|
|
|
@@ -75,6 +100,31 @@ module Varar
|
|
|
75
100
|
examples.concat(unit.rows)
|
|
76
101
|
next
|
|
77
102
|
end
|
|
103
|
+
if unit.is_a?(ReferenceUnit)
|
|
104
|
+
# Splice the referenced section's steps in at this position. Only
|
|
105
|
+
# the reference block itself is subject to the delimiter rule;
|
|
106
|
+
# everything it splices in belongs to the same sequence, so a
|
|
107
|
+
# section of several paragraphs stays one example.
|
|
108
|
+
resolve_reference(unit, doc, registry, workspace, diagnostics, []).each_with_index do |spliced, i|
|
|
109
|
+
if open && (i.positive? || !unit.preceded_by_delimiter)
|
|
110
|
+
merge_into(open, spliced, from_reference: true)
|
|
111
|
+
else
|
|
112
|
+
flush.call
|
|
113
|
+
open = start_merged(spliced)
|
|
114
|
+
# An example that OPENS with a reference is named by its own
|
|
115
|
+
# first matching paragraph, not by the section it pulls in, and
|
|
116
|
+
# it sits under THIS document's headings, not the section's.
|
|
117
|
+
open.name_from_reference = true
|
|
118
|
+
open.scope_stack = unit.scope_stack
|
|
119
|
+
open.start_offset = unit.span.start_offset
|
|
120
|
+
end
|
|
121
|
+
# A spliced unit's span is in the referenced document; the
|
|
122
|
+
# example's span is in this one. It ends at the reference block
|
|
123
|
+
# until a later paragraph of the example's own extends it.
|
|
124
|
+
open.end_offset = unit.span.end_offset
|
|
125
|
+
end
|
|
126
|
+
next
|
|
127
|
+
end
|
|
78
128
|
unless unit.matched
|
|
79
129
|
# Prose paragraph — a delimiter. Drop it and end the open example.
|
|
80
130
|
flush.call
|
|
@@ -89,15 +139,77 @@ module Varar
|
|
|
89
139
|
end
|
|
90
140
|
flush.call
|
|
91
141
|
|
|
92
|
-
ExecutionPlan.new(
|
|
142
|
+
ExecutionPlan.new(doc: doc, examples: examples, diagnostics: diagnostics)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Resolve one reference block into the step-bearing units of the section
|
|
146
|
+
# it names, recursively: a referenced section may itself contain
|
|
147
|
+
# reference blocks, to any depth (ADR 0016 leaves depth to the author's
|
|
148
|
+
# judgement). `chain` carries the sections currently being resolved so a
|
|
149
|
+
# repeat is reported as a cycle instead of recursing forever.
|
|
150
|
+
def resolve_reference(unit, from_doc, registry, workspace, diagnostics, chain)
|
|
151
|
+
ref = unit.reference
|
|
152
|
+
key = Reference.section_key(ref.path, ref.slug)
|
|
153
|
+
if chain.include?(key)
|
|
154
|
+
diagnostics << Diagnostics.reference_cycle(chain + [key], unit.span)
|
|
155
|
+
return []
|
|
156
|
+
end
|
|
157
|
+
# A same-file reference resolves against the document being planned,
|
|
158
|
+
# which is not necessarily in the workspace.
|
|
159
|
+
target = ref.path == from_doc.path ? from_doc : workspace.docs[ref.path]
|
|
160
|
+
if target.nil?
|
|
161
|
+
diagnostics << Diagnostics.reference_not_found(ref.text, ref.path, unit.span)
|
|
162
|
+
return []
|
|
163
|
+
end
|
|
164
|
+
# An anchor that names more than one heading could mean either section:
|
|
165
|
+
# report it rather than splicing both. A whole-file reference ('' slug)
|
|
166
|
+
# names no heading, so it is never ambiguous.
|
|
167
|
+
unless ref.slug.empty?
|
|
168
|
+
named = target.headings.select { |h| Reference.slugify(h.text) == ref.slug }
|
|
169
|
+
if named.length > 1
|
|
170
|
+
diagnostics << Diagnostics.ambiguous_anchor(ref.text, ref.path, ref.slug,
|
|
171
|
+
named.map { |h| h.span.start_line }, unit.span)
|
|
172
|
+
return []
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
out = []
|
|
176
|
+
Reference.section_candidates(target, ref.slug).each do |candidate|
|
|
177
|
+
planned = plan_candidate(candidate, target, registry, diagnostics)
|
|
178
|
+
if planned.is_a?(ReferenceUnit)
|
|
179
|
+
out.concat(resolve_reference(planned, target, registry, workspace, diagnostics, chain + [key]))
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
# A header-bound table produces one example per row, which a spliced
|
|
183
|
+
# step list cannot express; an `error` fence declares an outcome for
|
|
184
|
+
# an example, not for a reusable fragment. Both are left out.
|
|
185
|
+
next unless planned.is_a?(StepsUnit) && planned.matched
|
|
186
|
+
|
|
187
|
+
out << tag_with_doc(planned, target.path, from_doc.path)
|
|
188
|
+
end
|
|
189
|
+
diagnostics << Diagnostics.reference_empty(ref.text, ref.path, ref.slug, unit.span) if out.empty?
|
|
190
|
+
out
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Carry the source document's identity on every spliced step, so a failure
|
|
194
|
+
# in a referenced section reports spans against the file they were written
|
|
195
|
+
# in rather than the file being run.
|
|
196
|
+
def tag_with_doc(unit, doc_path, host_path)
|
|
197
|
+
return unit if doc_path == host_path
|
|
198
|
+
|
|
199
|
+
unit.with(steps: unit.steps.map { |step| step.with(doc_path: doc_path) })
|
|
93
200
|
end
|
|
94
201
|
|
|
95
202
|
def start_merged(unit)
|
|
96
203
|
MergedExample.new(unit.name, unit.scope_stack, unit.span.start_offset, unit.span.end_offset,
|
|
97
|
-
unit.steps.dup, unit.expected_outcome, unit.expected_error_message)
|
|
204
|
+
unit.steps.dup, unit.expected_outcome, unit.expected_error_message, false)
|
|
98
205
|
end
|
|
99
206
|
|
|
100
|
-
def merge_into(open, unit)
|
|
207
|
+
def merge_into(open, unit, from_reference: false)
|
|
208
|
+
if open.name_from_reference && !from_reference
|
|
209
|
+
open.name = unit.name
|
|
210
|
+
open.scope_stack = unit.scope_stack
|
|
211
|
+
open.name_from_reference = false
|
|
212
|
+
end
|
|
101
213
|
open.end_offset = unit.span.end_offset
|
|
102
214
|
open.steps.concat(unit.steps)
|
|
103
215
|
# Any error fence in a merged part marks the whole example
|
|
@@ -124,7 +236,18 @@ module Varar
|
|
|
124
236
|
|
|
125
237
|
# Plan a single candidate paragraph (plus attached tables/fences) in
|
|
126
238
|
# isolation. Emits ambiguity / error-fence diagnostics into +diagnostics+.
|
|
127
|
-
def plan_candidate(ex,
|
|
239
|
+
def plan_candidate(ex, doc, registry, diagnostics)
|
|
240
|
+
# A block whose whole text is a link to an oath section is a reference,
|
|
241
|
+
# not content: never matched against step definitions, never prose.
|
|
242
|
+
primary = ex.body.first
|
|
243
|
+
if primary.respond_to?(:text)
|
|
244
|
+
ref = Reference.reference_of(primary.text, doc.path)
|
|
245
|
+
if ref
|
|
246
|
+
return ReferenceUnit.new(reference: ref, preceded_by_delimiter: ex.preceded_by_delimiter,
|
|
247
|
+
span: ex.span, scope_stack: ex.scope_stack)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
128
251
|
had_ambiguous = false
|
|
129
252
|
steps_by_block = {}
|
|
130
253
|
|
|
@@ -135,7 +258,7 @@ module Varar
|
|
|
135
258
|
result = plan_block(block.text, registry)
|
|
136
259
|
|
|
137
260
|
result.ambiguities.each do |collision|
|
|
138
|
-
span = lift_span(
|
|
261
|
+
span = lift_span(doc.source, block, collision.match_start, collision.match_end)
|
|
139
262
|
cp_start = Offsets.cp_index_for_utf16(block.text, collision.match_start)
|
|
140
263
|
cp_end = Offsets.cp_index_for_utf16(block.text, collision.match_end)
|
|
141
264
|
diagnostics << Diagnostics.ambiguous_match(
|
|
@@ -159,8 +282,9 @@ module Varar
|
|
|
159
282
|
steps_by_block[idx] = result.steps.map do |hit|
|
|
160
283
|
PlannedStep.new(
|
|
161
284
|
text: Offsets.utf16_slice(block.text, hit.match_start, hit.match_end),
|
|
162
|
-
match_span: lift_span(
|
|
163
|
-
param_spans: hit.param_spans.map { |p| lift_span(
|
|
285
|
+
match_span: lift_span(doc.source, block, hit.match_start, hit.match_end),
|
|
286
|
+
param_spans: hit.param_spans.map { |p| lift_span(doc.source, block, p.start, p.end) },
|
|
287
|
+
param_texts: hit.param_spans.map { |p| Offsets.utf16_slice(block.text, p.start, p.end) },
|
|
164
288
|
step_def: hit.step_def,
|
|
165
289
|
args: hit.args,
|
|
166
290
|
formats: hit.formats
|
|
@@ -169,7 +293,7 @@ module Varar
|
|
|
169
293
|
end
|
|
170
294
|
|
|
171
295
|
# Header-bound table detection.
|
|
172
|
-
bound = had_ambiguous ? nil : detect_header_bound(ex, steps_by_block,
|
|
296
|
+
bound = had_ambiguous ? nil : detect_header_bound(ex, steps_by_block, doc.source)
|
|
173
297
|
if bound
|
|
174
298
|
table, binding_step, header_spans = bound
|
|
175
299
|
header_binding = HeaderBinding.new(
|
|
@@ -182,14 +306,7 @@ module Varar
|
|
|
182
306
|
table.header.cells.each_with_index do |cell_name, i|
|
|
183
307
|
row_object[cell_name] = i < row.cells.length ? row.cells[i] : ''
|
|
184
308
|
end
|
|
185
|
-
row_step =
|
|
186
|
-
text: binding_step.text,
|
|
187
|
-
match_span: row.span,
|
|
188
|
-
param_spans: binding_step.param_spans,
|
|
189
|
-
step_def: binding_step.step_def,
|
|
190
|
-
args: binding_step.args + [row_object],
|
|
191
|
-
formats: binding_step.formats
|
|
192
|
-
)
|
|
309
|
+
row_step = binding_step.with(match_span: row.span, args: binding_step.args + [row_object])
|
|
193
310
|
row_checks = table.header.cells.each_with_index.map do |cell_name, i|
|
|
194
311
|
RowCheck.new(
|
|
195
312
|
column: cell_name,
|
|
@@ -236,11 +353,7 @@ module Varar
|
|
|
236
353
|
block_steps.each_with_index do |step, s_idx|
|
|
237
354
|
if s_idx == block_steps.length - 1 && attach
|
|
238
355
|
data_table, doc_string = attach
|
|
239
|
-
final_steps <<
|
|
240
|
-
text: step.text, match_span: step.match_span, param_spans: step.param_spans,
|
|
241
|
-
step_def: step.step_def, args: step.args, formats: step.formats,
|
|
242
|
-
data_table: data_table, doc_string: doc_string
|
|
243
|
-
)
|
|
356
|
+
final_steps << step.with(data_table: data_table, doc_string: doc_string)
|
|
244
357
|
else
|
|
245
358
|
final_steps << step
|
|
246
359
|
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'varar/core/ast'
|
|
4
|
+
|
|
5
|
+
module Varar
|
|
6
|
+
module Core
|
|
7
|
+
# Reuse is a link (ADR 0016). A candidate block whose entire content is a
|
|
8
|
+
# single Markdown link to an oath section is a REFERENCE BLOCK: it splices
|
|
9
|
+
# that section's steps in at its own position instead of being prose.
|
|
10
|
+
#
|
|
11
|
+
# Everything here is pure text and path arithmetic — no filesystem. The
|
|
12
|
+
# shell reads the documents; `references` tells it which ones to read, and
|
|
13
|
+
# `build_workspace` turns the collection into what `plan` needs.
|
|
14
|
+
module Reference
|
|
15
|
+
# The referenced oath's path (resolved against the referring doc's own
|
|
16
|
+
# path), the GFM slug of the heading, and the link's visible text.
|
|
17
|
+
Ref = Data.define(:path, :slug, :text)
|
|
18
|
+
|
|
19
|
+
# What `plan` needs to resolve references: every oath by path, plus which
|
|
20
|
+
# sections a reference block consumes somewhere in the project. A section
|
|
21
|
+
# that is referenced stops being a standalone example, so this is
|
|
22
|
+
# whole-project knowledge — see ADR 0016 on why each runner builds it at
|
|
23
|
+
# its once-per-run discovery pass.
|
|
24
|
+
Workspace = Data.define(:docs, :referenced)
|
|
25
|
+
|
|
26
|
+
# A candidate is a reference block iff its whole text is one Markdown link
|
|
27
|
+
# whose target is oath-shaped. Anything else — a link with surrounding
|
|
28
|
+
# words, a link to https://…, to a .rb file, to a mailto: — is ordinary
|
|
29
|
+
# content, so existing documents keep their meaning.
|
|
30
|
+
LINK_ONLY = /\A\[([^\]]*)\]\(\s*([^\s)]+)\s*\)\z/
|
|
31
|
+
PROTOCOL = /\A[a-z][a-z0-9+.-]*:/i
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def reference_of(text, from_path)
|
|
36
|
+
m = LINK_ONLY.match(text.strip)
|
|
37
|
+
return nil if m.nil?
|
|
38
|
+
|
|
39
|
+
link_text = m[1]
|
|
40
|
+
target = m[2]
|
|
41
|
+
return Ref.new(path: from_path, slug: normalize_slug(target[1..]), text: link_text) if target.start_with?('#')
|
|
42
|
+
|
|
43
|
+
hash_at = target.index('#')
|
|
44
|
+
file_part = hash_at.nil? ? target : target[0...hash_at]
|
|
45
|
+
fragment = hash_at.nil? ? '' : target[(hash_at + 1)..]
|
|
46
|
+
# Only a relative Markdown path is a reference. A protocol (https:,
|
|
47
|
+
# mailto:) or any other extension is left alone — remote references are
|
|
48
|
+
# deliberately out of scope (ADR 0016).
|
|
49
|
+
return nil unless file_part.end_with?('.md')
|
|
50
|
+
return nil if PROTOCOL.match?(file_part) || file_part.start_with?('/')
|
|
51
|
+
|
|
52
|
+
Ref.new(path: join_posix(dirname_posix(from_path), file_part), slug: normalize_slug(fragment),
|
|
53
|
+
text: link_text)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# GitHub's heading anchors: inline markup dropped, lowercased, spaces to
|
|
57
|
+
# hyphens, everything else that isn't a word character or hyphen removed.
|
|
58
|
+
# The same function produces the slug of a heading and normalizes the slug
|
|
59
|
+
# written in a link, so the two meet in the middle.
|
|
60
|
+
def slugify(heading_text)
|
|
61
|
+
stripped = heading_text.gsub(/`([^`]*)`/, '\1')
|
|
62
|
+
.gsub(/\*\*([^*]*)\*\*/, '\1')
|
|
63
|
+
.gsub(/\*([^*]*)\*/, '\1')
|
|
64
|
+
.gsub(/_([^_]*)_/, '\1')
|
|
65
|
+
normalize_slug(stripped)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# One hyphen per space, not per run of them: GitHub leaves the gap where
|
|
69
|
+
# it dropped punctuation, so "Fees, VAT & rounding" slugs with a double
|
|
70
|
+
# hyphen.
|
|
71
|
+
def normalize_slug(str)
|
|
72
|
+
str.strip.downcase.gsub(/[^[[:word:]] -]/, '').tr(' ', '-')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def dirname_posix(path)
|
|
76
|
+
i = path.rindex('/')
|
|
77
|
+
i.nil? ? '' : path[0...i]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# POSIX path arithmetic on oath paths (always '/'-separated, relative to
|
|
81
|
+
# the workspace root). The core may not touch the filesystem. A link that
|
|
82
|
+
# climbs above the root keeps its leading `../`, as the oath-path
|
|
83
|
+
# convention does for an oath outside the root.
|
|
84
|
+
def join_posix(dir, rel)
|
|
85
|
+
segments = dir.empty? ? [] : dir.split('/')
|
|
86
|
+
rel.split('/').each do |segment|
|
|
87
|
+
next if segment.empty? || segment == '.'
|
|
88
|
+
|
|
89
|
+
if segment != '..'
|
|
90
|
+
segments << segment
|
|
91
|
+
elsif !segments.empty? && segments.last != '..'
|
|
92
|
+
segments.pop
|
|
93
|
+
else
|
|
94
|
+
segments << '..'
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
segments.join('/')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Every reference block in a document, in document order. The shell uses
|
|
101
|
+
# this to walk the closure of documents it must read before planning.
|
|
102
|
+
def references(doc)
|
|
103
|
+
doc.examples.filter_map do |ex|
|
|
104
|
+
primary = ex.body.first
|
|
105
|
+
next nil unless primary.respond_to?(:text)
|
|
106
|
+
|
|
107
|
+
reference_of(primary.text, doc.path)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def section_key(path, slug)
|
|
112
|
+
"#{path}##{slug}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The workspace with no references at all: what a caller planning a single
|
|
116
|
+
# document in isolation passes.
|
|
117
|
+
def empty_workspace
|
|
118
|
+
Workspace.new(docs: {}, referenced: Set.new)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_workspace(docs)
|
|
122
|
+
by_path = docs.to_h { |doc| [doc.path, doc] }
|
|
123
|
+
referenced = Set.new
|
|
124
|
+
docs.each do |doc|
|
|
125
|
+
references(doc).each { |ref| referenced << section_key(ref.path, ref.slug) }
|
|
126
|
+
end
|
|
127
|
+
Workspace.new(docs: by_path, referenced: referenced)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The candidates that make up a section: those whose heading chain
|
|
131
|
+
# contains the slug. A whole-file reference ('' slug) is every candidate.
|
|
132
|
+
# Section membership follows the document outline exactly — a heading's
|
|
133
|
+
# section runs until the next heading of the same or higher level, which
|
|
134
|
+
# is precisely the range over which it stays on the scope stack.
|
|
135
|
+
def section_candidates(doc, slug)
|
|
136
|
+
return doc.examples if slug.empty?
|
|
137
|
+
|
|
138
|
+
doc.examples.select { |ex| ex.scope_stack.any? { |h| slugify(h) == slug } }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/varar/core/registry.rb
CHANGED
|
@@ -39,7 +39,7 @@ module Varar
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
# Seed Varar's own built-in parameter types (beyond cucumber-expressions'
|
|
42
|
-
# int/float/string/word). Shared by every port so
|
|
42
|
+
# int/float/string/word). Shared by every port so oaths match
|
|
43
43
|
# identically. Built-ins are NOT tracked as custom parameter types, so
|
|
44
44
|
# they never appear in the conformance registry.json projection.
|
|
45
45
|
def seed_builtins(registry)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Varar
|
|
4
|
+
module Core
|
|
5
|
+
# Run-result records — port of result.ts / result.rb's siblings in every
|
|
6
|
+
# other port. The persisted .varar/<oath_path>.json file is a serialized
|
|
7
|
+
# OathResults, read by the language server to place run diagnostics in the
|
|
8
|
+
# editor (ADR 0014).
|
|
9
|
+
|
|
10
|
+
# One mismatched CELL as a source-offset range plus the runtime value.
|
|
11
|
+
# `from`/`to` are absolute UTF-16 source offsets; `to` is exclusive.
|
|
12
|
+
CellFailure = Data.define(:from, :to, :actual)
|
|
13
|
+
|
|
14
|
+
# Where a failure points in the source: an offset range, `to` exclusive.
|
|
15
|
+
# The failing step's match span, or the first mismatched cell's span (the
|
|
16
|
+
# failure_anchor rule) — what lets a renderer underline the step that
|
|
17
|
+
# failed rather than the whole line it sits on.
|
|
18
|
+
AnchorRange = Data.define(:from, :to)
|
|
19
|
+
|
|
20
|
+
# The failure payload of a failed ExampleResult. `cells` and `anchor` are
|
|
21
|
+
# nil when they do not apply, and serialize as absent (not null), so a
|
|
22
|
+
# reader that predates them still parses the file. `stack` is deliberately
|
|
23
|
+
# runtime-shaped — no consumer parses it.
|
|
24
|
+
# `doc_path` is the document `line`, `cells` and `anchor` are offsets INTO.
|
|
25
|
+
# nil — the overwhelming majority — means the oath itself; set only for a
|
|
26
|
+
# step a reference block spliced in from another oath (ADR 0016).
|
|
27
|
+
ExampleFailure = Data.define(:line, :message, :stack, :cells, :anchor, :doc_path) do
|
|
28
|
+
def initialize(line:, message:, stack:, cells: nil, anchor: nil, doc_path: nil)
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The run result for one BDD example. `lines` are the 1-based source lines
|
|
34
|
+
# of its steps (the editor's line-wash anchors).
|
|
35
|
+
ExampleResult = Data.define(:name, :status, :lines, :failure) do
|
|
36
|
+
def initialize(name:, status:, lines:, failure: nil)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The persisted run result for one oath file. `oath_path` uses POSIX
|
|
42
|
+
# separators and is relative to the workspace root; `source_hash` is
|
|
43
|
+
# Hashing.hash_source over the oath as it was run, so a reader can tell
|
|
44
|
+
# whether the offsets still apply to the buffer in front of it.
|
|
45
|
+
# An oath other than this one that contributed steps to the run, with its
|
|
46
|
+
# source hash as run (ADR 0016).
|
|
47
|
+
ReferencedDocument = Data.define(:path, :source_hash)
|
|
48
|
+
|
|
49
|
+
OathResults = Data.define(:version, :oath_path, :source_hash, :examples, :documents) do
|
|
50
|
+
def initialize(version:, oath_path:, source_hash:, examples:, documents: [])
|
|
51
|
+
super
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Projection of OathResults onto the JSON shape of .varar/<oath_path>.json.
|
|
56
|
+
#
|
|
57
|
+
# The wire format is the TypeScript one (ADR 0014): camelCase names,
|
|
58
|
+
# declaration order, and the optional members absent rather than null so a
|
|
59
|
+
# reader that predates them still parses the file. Pure — writing the file
|
|
60
|
+
# is the shell's job.
|
|
61
|
+
module Results
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
def to_wire(results)
|
|
65
|
+
out = {
|
|
66
|
+
'version' => results.version,
|
|
67
|
+
'oathPath' => results.oath_path,
|
|
68
|
+
'sourceHash' => results.source_hash
|
|
69
|
+
}
|
|
70
|
+
unless results.documents.empty?
|
|
71
|
+
out['documents'] = results.documents.map { |d| { 'path' => d.path, 'sourceHash' => d.source_hash } }
|
|
72
|
+
end
|
|
73
|
+
out['examples'] = results.examples.map { |e| example_to_wire(e) }
|
|
74
|
+
out
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def example_to_wire(example)
|
|
78
|
+
out = { 'name' => example.name, 'status' => example.status, 'lines' => example.lines.to_a }
|
|
79
|
+
out['failure'] = failure_to_wire(example.failure) if example.failure
|
|
80
|
+
out
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def failure_to_wire(failure)
|
|
84
|
+
out = { 'line' => failure.line, 'message' => failure.message, 'stack' => failure.stack }
|
|
85
|
+
if failure.cells && !failure.cells.empty?
|
|
86
|
+
out['cells'] = failure.cells.map { |c| { 'from' => c.from, 'to' => c.to, 'actual' => c.actual } }
|
|
87
|
+
end
|
|
88
|
+
out['anchor'] = { 'from' => failure.anchor.from, 'to' => failure.anchor.to } if failure.anchor
|
|
89
|
+
out['docPath'] = failure.doc_path if failure.doc_path
|
|
90
|
+
out
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -17,6 +17,7 @@ module Varar
|
|
|
17
17
|
def structure(path, source, blocks)
|
|
18
18
|
examples = []
|
|
19
19
|
orphan_attachments = []
|
|
20
|
+
headings = []
|
|
20
21
|
scope_stack = [] # [[level, text], ...]
|
|
21
22
|
last_example_idx = -1
|
|
22
23
|
attachment_open = false
|
|
@@ -32,6 +33,7 @@ module Varar
|
|
|
32
33
|
# Pop deeper-or-equal-level entries before pushing the new heading.
|
|
33
34
|
scope_stack.pop while !scope_stack.empty? && scope_stack.last[0] >= block.level
|
|
34
35
|
scope_stack << [block.level, block.text]
|
|
36
|
+
headings << block
|
|
35
37
|
attachment_open = false
|
|
36
38
|
delimiter_pending = true
|
|
37
39
|
|
|
@@ -66,11 +68,12 @@ module Varar
|
|
|
66
68
|
end
|
|
67
69
|
end
|
|
68
70
|
|
|
69
|
-
|
|
71
|
+
Doc.new(
|
|
70
72
|
path: path,
|
|
71
73
|
source: source,
|
|
72
74
|
examples: examples,
|
|
73
|
-
orphan_attachments: orphan_attachments
|
|
75
|
+
orphan_attachments: orphan_attachments,
|
|
76
|
+
headings: headings
|
|
74
77
|
)
|
|
75
78
|
end
|
|
76
79
|
end
|
data/lib/varar/core.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Varar
|
|
|
4
4
|
# The pure functional core: parse, match, plan, execute, diffs, drift, and
|
|
5
5
|
# the conformance projections. No filesystem, network, globals, or time.
|
|
6
6
|
module Core
|
|
7
|
-
VERSION = '0.
|
|
7
|
+
VERSION = '0.8.1'
|
|
8
8
|
end
|
|
9
9
|
end
|
|
10
10
|
|
|
@@ -24,6 +24,8 @@ require 'varar/core/plan'
|
|
|
24
24
|
require 'varar/core/doc_string_diff'
|
|
25
25
|
require 'varar/core/param_diff'
|
|
26
26
|
require 'varar/core/failure_anchor'
|
|
27
|
+
require 'varar/core/result'
|
|
28
|
+
require 'varar/core/failure'
|
|
27
29
|
require 'varar/core/execute'
|
|
28
30
|
require 'varar/core/hash'
|
|
29
31
|
require 'varar/core/drift'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: varar-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Aslak Hellesøy
|
|
@@ -40,13 +40,16 @@ files:
|
|
|
40
40
|
- lib/varar/core/doc_string_diff.rb
|
|
41
41
|
- lib/varar/core/drift.rb
|
|
42
42
|
- lib/varar/core/execute.rb
|
|
43
|
+
- lib/varar/core/failure.rb
|
|
43
44
|
- lib/varar/core/failure_anchor.rb
|
|
44
45
|
- lib/varar/core/hash.rb
|
|
45
46
|
- lib/varar/core/matcher.rb
|
|
46
47
|
- lib/varar/core/param_diff.rb
|
|
47
48
|
- lib/varar/core/parse.rb
|
|
48
49
|
- lib/varar/core/plan.rb
|
|
50
|
+
- lib/varar/core/reference.rb
|
|
49
51
|
- lib/varar/core/registry.rb
|
|
52
|
+
- lib/varar/core/result.rb
|
|
50
53
|
- lib/varar/core/scanner.rb
|
|
51
54
|
- lib/varar/core/sentences.rb
|
|
52
55
|
- lib/varar/core/span.rb
|