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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d857a484c127ac8dc313cff7dc855f7924e9b90dd9935b3575e880a4abc5e9c
4
- data.tar.gz: becbdbfe8eda5afc06b7506b719fafd3868e1506cec1e484e461766b7b39225a
3
+ metadata.gz: 76ca7dbd630ae0e9925114d31967993e9a0f366a83844da58778afffaa1bf555
4
+ data.tar.gz: e37e4f44a2469a038ba6c733b6098249501c6c8bcdbc9e6db84a4d0219872bed
5
5
  SHA512:
6
- metadata.gz: f1f17d5bf2fef77cc8c8dbb4d0991c72232b08d0fe139f0c60768016c86b2eeeca33e54b66a4098ba1ab5bf15cbb00aa91f0a3e48e9ceadc7c457c630085d62c
7
- data.tar.gz: 05404cecafaeca822ede078824c9bfad5d959236ea7c23f00a8b44a9d0ffe4a002ff9363d7c52bc86649d2d3bfd6b353f68c67c1131ac0c867172beac0e0913f
6
+ metadata.gz: 5e9e258f4c033d489f529ddccb3d460afbae2c064ac58de82bed5f25b166ecc295d3c3537ad257d1a88b06784f8f21e3df26d808c5d36f521c66c3a147af9881
7
+ data.tar.gz: 9dc4d46d4052654752e5736b1703236e517e95412c193bd7851d1c37d6c2a88e4eea6f77c9aee7617272f769a796eb608d85111d243125b1209227ff95320454
@@ -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
- VarDoc = Data.define(:path, :source, :examples, :orphan_attachments)
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
- # JSON serializers byte-for-byte compatible with JS `JSON.stringify(v, null, 2)`:
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
- # {}/[]. `canonical_stringify` recursively sorts object keys (the goldens);
10
- # `ordered_stringify` preserves insertion order (varar.lock.json).
9
+ # {}/[], keys in insertion order.
11
10
  #
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).
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, '', sort_keys: false)}\n"
24
+ "#{encode(value, '')}\n"
24
25
  end
25
26
 
26
- def encode(value, indent, sort_keys:)
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, sort_keys: sort_keys)}" }
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, sort_keys: sort_keys)}" }
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 VarDoc to the wire dict for the var-doc artifact.
102
- def to_var_doc_artifact(doc)
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.var_doc.source
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, source)
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.param_spans.each_with_index.map do |s, i|
170
+ 'args' => step.param_texts.each_with_index.map do |value, i|
169
171
  {
170
- 'value' => Offsets.utf16_slice(source, s.start_offset, s.end_offset),
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(var_doc, registry, create_context, parameter_types = [])
210
- execution = Plan.plan(var_doc, registry)
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
- var_doc: to_var_doc_artifact(var_doc),
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". Port of
7
- # diagnostics.ts.
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
@@ -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 spec file.
14
- SpecBaseline = Data.define(:source_hash, :examples)
15
- # The whole varar.lock.json: every spec keyed by its POSIX path.
16
- VarLock = Data.define(:version, :specs)
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
- # Spec drift detection: a paragraph the committed varar.lock.json baseline
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(var_doc, plan)
66
- var_doc.examples.filter_map do |candidate|
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 derive_spec_baseline(source, var_doc, plan)
74
- SpecBaseline.new(source_hash: Hash32.hash_source(source), examples: live_examples(var_doc, plan))
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, var_doc, plan)
80
+ def detect_drift(baseline, doc, plan)
81
81
  return [] if baseline.nil?
82
82
 
83
- candidates = var_doc.examples
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 spec's baseline reconciliation against a BaselineStore. In update
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, spec_path, source, var_doc, plan, update: false)
118
+ def reconcile_drift(store, oath_path, source, doc, plan, update: false)
119
119
  text = store.read
120
- lock = text ? parse_var_lock(text) : nil
121
- baseline = lock ? lock.specs[spec_path] : nil
122
- drifts = update ? [] : detect_drift(baseline, var_doc, plan)
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
- specs = lock ? lock.specs.dup : {}
125
- specs[spec_path] = derive_spec_baseline(source, var_doc, plan)
126
- store.write(stringify_var_lock(VarLock.new(version: 1, specs: specs)))
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
- def parse_var_lock(text)
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'] == 1
166
+ return nil unless parsed.is_a?(::Hash) && parsed['version'] == 2
134
167
 
135
- specs_raw = parsed['specs']
136
- return nil unless specs_raw.is_a?(::Hash)
168
+ oaths_raw = parsed['oaths']
169
+ return nil unless oaths_raw.is_a?(::Hash)
137
170
 
138
- specs = {}
139
- specs_raw.each do |path, value|
140
- baseline = parse_spec_baseline(value)
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
- specs[path] = baseline
176
+ oaths[path] = baseline
144
177
  end
145
- VarLock.new(version: 1, specs: specs)
178
+ LockFile.new(version: 2, oaths: oaths)
146
179
  rescue JSON::ParserError, TypeError
147
180
  nil
148
181
  end
149
182
 
150
- def parse_spec_baseline(value)
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
- SpecBaseline.new(source_hash: source_hash, examples: examples)
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: spec paths sorted, examples
178
- # in document order, insertion-order keys otherwise (version, specs;
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 stringify_var_lock(lock)
181
- specs = {}
182
- lock.specs.keys.sort.each do |path|
183
- baseline = lock.specs[path]
184
- specs[path] = {
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' => 1, 'specs' => specs })
222
+ CanonicalJson.ordered_stringify({ 'version' => 2, 'oaths' => oaths })
190
223
  end
191
224
  end
192
225
  end
@@ -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
- var_path = plan.var_doc.path
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, var_path), info)
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, var_path)
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, var_path)
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, var_path)
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, var_path) : error)
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.var_doc.source, s.start_offset, s.end_offset)
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; the conformance trace derives the anchor separately
203
- # via failure_anchor, so here it is a no-op that returns the error.
204
- def augment_stack(error, _step, _var_path)
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
@@ -5,7 +5,7 @@ require 'varar/core/structurer'
5
5
 
6
6
  module Varar
7
7
  module Core
8
- # Parse +source+ into a VarDoc: scan blocks, then group into Examples.
8
+ # Parse +source+ into a Doc: scan blocks, then group into Examples.
9
9
  # Port of parse.ts.
10
10
  module Parse
11
11
  module_function
@@ -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
- PlannedStep = Data.define(:text, :match_span, :param_spans, :step_def, :args, :formats, :data_table,
15
- :doc_string) do
16
- def initialize(text:, match_span:, param_spans:, step_def:, args:, formats: [], data_table: nil,
17
- doc_string: nil)
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(:var_doc, :examples, :diagnostics)
38
+ ExecutionPlan = Data.define(:doc, :examples, :diagnostics)
33
39
 
34
- # Produce an ExecutionPlan from a VarDoc + Registry: match step expressions
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(var_doc, registry)
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 = var_doc.examples.map { |ex| plan_candidate(ex, var_doc, registry, diagnostics) }
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, var_doc.source) if 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(var_doc: var_doc, examples: examples, diagnostics: diagnostics)
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, var_doc, registry, diagnostics)
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(var_doc.source, block, collision.match_start, collision.match_end)
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(var_doc.source, block, hit.match_start, hit.match_end),
163
- param_spans: hit.param_spans.map { |p| lift_span(var_doc.source, block, p.start, p.end) },
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, var_doc.source)
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 = PlannedStep.new(
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 << PlannedStep.new(
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
@@ -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 specs match
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
- VarDoc.new(
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.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.7.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