featureparity 0.0.2 → 0.0.4
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/fp/cli.rb +3 -1
- data/lib/fp/client.rb +24 -17
- data/lib/fp/commands/base.rb +7 -7
- data/lib/fp/commands/config_cmd.rb +10 -1
- data/lib/fp/commands/help.rb +44 -1
- data/lib/fp/commands/list.rb +34 -18
- data/lib/fp/commands/matrix.rb +7 -8
- data/lib/fp/commands/profile.rb +28 -7
- data/lib/fp/commands/projects.rb +11 -5
- data/lib/fp/commands/propose.rb +19 -8
- data/lib/fp/commands/report.rb +161 -12
- data/lib/fp/commands/repos.rb +169 -0
- data/lib/fp/commands/setup.rb +47 -8
- data/lib/fp/commands/show.rb +3 -2
- data/lib/fp/commands/surfaces.rb +5 -5
- data/lib/fp/commands/whoami.rb +55 -0
- data/lib/fp/commands.rb +2 -0
- data/lib/fp/config.rb +245 -11
- data/lib/fp/junit.rb +212 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +140 -0
- data/skills/fp/references/cli.md +245 -0
- data/skills/fp/references/markers.md +149 -0
- metadata +8 -2
data/lib/fp/config.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
require 'fileutils'
|
|
5
|
+
require 'uri'
|
|
5
6
|
|
|
6
7
|
module Fp
|
|
7
8
|
# Handles configuration: profiles, API keys, API URL.
|
|
@@ -15,17 +16,26 @@ module Fp
|
|
|
15
16
|
#
|
|
16
17
|
# API key resolution priority:
|
|
17
18
|
# 1. FP_API_KEY env var
|
|
18
|
-
# 2. Profile api_key (--profile flag > FP_PROFILE env var)
|
|
19
|
+
# 2. Profile api_key (--profile flag > FP_PROFILE env var > "default" profile)
|
|
20
|
+
#
|
|
21
|
+
# Profile name resolution priority:
|
|
22
|
+
# 1. --profile CLI flag
|
|
23
|
+
# 2. FP_PROFILE env var
|
|
24
|
+
# 3. A profile literally named "default" (if one exists in config.yml)
|
|
19
25
|
class Config
|
|
20
26
|
DEFAULT_API_URL = 'https://api.featureparity.dev'
|
|
27
|
+
DEFAULT_PROFILE_NAME = 'default'
|
|
21
28
|
CONFIG_DIR = File.expand_path('~/.config/fp')
|
|
22
29
|
CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml')
|
|
23
30
|
|
|
24
31
|
attr_reader :api_key, :api_url, :profile_name
|
|
25
32
|
|
|
33
|
+
# Where a resolved value came from, for `fp whoami` transparency.
|
|
34
|
+
attr_reader :api_key_source, :api_url_source, :profile_source
|
|
35
|
+
|
|
26
36
|
def initialize(profile: nil, api_url: nil)
|
|
27
|
-
@profile_name = resolve_profile_name(profile)
|
|
28
37
|
@explicit_api_url = api_url
|
|
38
|
+
@profile_name = resolve_profile_name(profile)
|
|
29
39
|
@api_key = resolve_api_key
|
|
30
40
|
@api_url = resolve_api_url
|
|
31
41
|
end
|
|
@@ -34,6 +44,56 @@ module Fp
|
|
|
34
44
|
!@api_key.nil? && !@api_key.empty?
|
|
35
45
|
end
|
|
36
46
|
|
|
47
|
+
# The API URL passed via the global --api-url flag, if any (nil otherwise).
|
|
48
|
+
# `profile add` / `setup` use this so `fp profile add x --api-url URL` still
|
|
49
|
+
# records URL even though the global parser consumes the flag first.
|
|
50
|
+
def explicit_api_url
|
|
51
|
+
return nil if @explicit_api_url.nil? || @explicit_api_url.empty?
|
|
52
|
+
|
|
53
|
+
@explicit_api_url
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Short, human-readable environment label derived from the API host, e.g.
|
|
57
|
+
# https://api.dev.featureparity.dev -> "dev"
|
|
58
|
+
# https://api.staging.featureparity.dev -> "staging"
|
|
59
|
+
# https://api.featureparity.dev -> "production"
|
|
60
|
+
# http://localhost:9292 -> "local"
|
|
61
|
+
# This is what "tags" a profile/key to an environment: the API URL it targets.
|
|
62
|
+
def environment
|
|
63
|
+
self.class.environment_for(@api_url)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The environment explicitly recorded on the active profile, if any.
|
|
67
|
+
# Falls back to nil when unset (callers derive from api_url instead).
|
|
68
|
+
def profile_environment
|
|
69
|
+
return nil unless @profile_name
|
|
70
|
+
|
|
71
|
+
profile = self.class.load_profiles[@profile_name]
|
|
72
|
+
profile && profile['environment']
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Masked hint for display — never reveal the full key.
|
|
76
|
+
def masked_api_key
|
|
77
|
+
self.class.mask_key(@api_key)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Full resolved snapshot for `fp whoami` (safe to print / JSON-encode).
|
|
81
|
+
def resolved
|
|
82
|
+
{
|
|
83
|
+
profile: @profile_name,
|
|
84
|
+
profile_source: @profile_source,
|
|
85
|
+
api_url: @api_url,
|
|
86
|
+
api_url_source: @api_url_source,
|
|
87
|
+
environment: environment,
|
|
88
|
+
profile_environment: profile_environment,
|
|
89
|
+
authenticated: valid?,
|
|
90
|
+
api_key_present: valid?,
|
|
91
|
+
api_key_hint: masked_api_key,
|
|
92
|
+
api_key_source: @api_key_source,
|
|
93
|
+
config_file: self.class::CONFIG_FILE
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
37
97
|
def validation_error
|
|
38
98
|
return nil if valid?
|
|
39
99
|
|
|
@@ -58,7 +118,7 @@ module Fp
|
|
|
58
118
|
config['profiles'] || {}
|
|
59
119
|
end
|
|
60
120
|
|
|
61
|
-
def save_profile(name, api_key:, api_url: nil)
|
|
121
|
+
def save_profile(name, api_key:, api_url: nil, environment: nil)
|
|
62
122
|
FileUtils.mkdir_p(CONFIG_DIR)
|
|
63
123
|
ensure_config_file!
|
|
64
124
|
|
|
@@ -66,6 +126,10 @@ module Fp
|
|
|
66
126
|
config['profiles'] ||= {}
|
|
67
127
|
config['profiles'][name] = { 'api_key' => api_key }
|
|
68
128
|
config['profiles'][name]['api_url'] = api_url if api_url
|
|
129
|
+
# Tag the key/profile with the environment it targets. If not given
|
|
130
|
+
# explicitly, derive it from the api_url so keys are always tagged.
|
|
131
|
+
env = environment || environment_for(api_url || get_setting('api_url') || DEFAULT_API_URL)
|
|
132
|
+
config['profiles'][name]['environment'] = env if env
|
|
69
133
|
|
|
70
134
|
write_config!(config)
|
|
71
135
|
end
|
|
@@ -76,6 +140,67 @@ module Fp
|
|
|
76
140
|
config[key.to_s]
|
|
77
141
|
end
|
|
78
142
|
|
|
143
|
+
# Derive a short environment label from an API URL's host. This mirrors
|
|
144
|
+
# the Belt DNS convention (see infrastructure/modules/app/dns.tf), which is
|
|
145
|
+
# what actually "tags" a profile/key to an environment: the API host tells
|
|
146
|
+
# you which FeatureParity environment the key talks to.
|
|
147
|
+
#
|
|
148
|
+
# Standalone environments use a dotted "api." prefix:
|
|
149
|
+
# api.featureparity.dev -> "production"
|
|
150
|
+
# api.dev.featureparity.dev -> "dev"
|
|
151
|
+
# api.staging.featureparity.dev -> "staging"
|
|
152
|
+
#
|
|
153
|
+
# Nested / ephemeral (per-branch) environments use a dashed "api-" prefix,
|
|
154
|
+
# because SSL wildcards are single-level (*.dev.featureparity.dev covers
|
|
155
|
+
# api-<env>.dev.featureparity.dev but not api.<env>.dev.featureparity.dev).
|
|
156
|
+
# The env slug is everything after "api-" up to the first dot, and may
|
|
157
|
+
# itself contain dashes:
|
|
158
|
+
# api-abc-1234.dev.featureparity.dev -> "abc-1234"
|
|
159
|
+
# api-fizzy123.dev.featureparity.dev -> "fizzy123"
|
|
160
|
+
#
|
|
161
|
+
# Anything local:
|
|
162
|
+
# localhost / 127.0.0.1 / 0.0.0.0 -> "local"
|
|
163
|
+
def environment_for(url)
|
|
164
|
+
return nil if url.nil? || url.to_s.empty?
|
|
165
|
+
|
|
166
|
+
host = begin
|
|
167
|
+
URI.parse(url.to_s).host
|
|
168
|
+
rescue URI::InvalidURIError
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
return nil if host.nil? || host.empty?
|
|
172
|
+
|
|
173
|
+
return 'local' if host == 'localhost' || host.start_with?('127.') || host == '0.0.0.0'
|
|
174
|
+
|
|
175
|
+
first, *rest = host.split('.')
|
|
176
|
+
|
|
177
|
+
# Nested/ephemeral: "api-<env>" where <env> may contain dashes.
|
|
178
|
+
return first.sub(/\Aapi-/, '') if first.start_with?('api-')
|
|
179
|
+
|
|
180
|
+
# Standalone dotted "api." prefix: the env is the next label.
|
|
181
|
+
if first == 'api'
|
|
182
|
+
# api.featureparity.dev -> ["featureparity", "dev"] -> production
|
|
183
|
+
return 'production' if rest.length <= 2
|
|
184
|
+
|
|
185
|
+
return rest.first
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# No recognizable "api" prefix — best effort: treat a bare apex
|
|
189
|
+
# (two labels, e.g. featureparity.dev) as production, else the left label.
|
|
190
|
+
return 'production' if rest.length <= 1
|
|
191
|
+
|
|
192
|
+
first
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Mask an API key for display: keep a short prefix + last 4 chars.
|
|
196
|
+
# fp_abcdefgh1234 -> fp_…1234
|
|
197
|
+
def mask_key(key)
|
|
198
|
+
return nil if key.nil? || key.empty?
|
|
199
|
+
return '****' if key.length <= 8
|
|
200
|
+
|
|
201
|
+
"#{key[0, 3]}…#{key[-4..]}"
|
|
202
|
+
end
|
|
203
|
+
|
|
79
204
|
# Set a global setting (top-level key in config.yml)
|
|
80
205
|
def set_setting(key, value)
|
|
81
206
|
FileUtils.mkdir_p(CONFIG_DIR)
|
|
@@ -99,6 +224,72 @@ module Fp
|
|
|
99
224
|
load_profiles.key?(name)
|
|
100
225
|
end
|
|
101
226
|
|
|
227
|
+
# --- Repo → surface mappings (per-machine, local) ---
|
|
228
|
+
#
|
|
229
|
+
# Stored under a top-level `repos` key in config.yml, scoped by project slug:
|
|
230
|
+
#
|
|
231
|
+
# repos:
|
|
232
|
+
# stowzilla:
|
|
233
|
+
# customer_android:
|
|
234
|
+
# path: /home/dev/code/customer-android
|
|
235
|
+
# repo: stowzilla/customer-android
|
|
236
|
+
#
|
|
237
|
+
# This is intentionally local (per fp installation) — it maps FeatureParity
|
|
238
|
+
# surfaces to wherever the code lives on THIS machine, so an agent working a
|
|
239
|
+
# requirement for a given surface knows which directory to open. Analogous to
|
|
240
|
+
# how `brainiac projects` maps repos to project names.
|
|
241
|
+
|
|
242
|
+
# Return the full repos mapping ({ project => { surface => {path, repo} } }).
|
|
243
|
+
def repos_map
|
|
244
|
+
config = load_config
|
|
245
|
+
config['repos'] || {}
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Return the surface => {path, repo} mapping for a single project.
|
|
249
|
+
def repos_for_project(project)
|
|
250
|
+
repos_map[project.to_s] || {}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Look up the mapping for a single surface within a project.
|
|
254
|
+
# Returns a hash like { 'path' => ..., 'repo' => ... } or nil if unmapped.
|
|
255
|
+
def repo_for_surface(project, surface)
|
|
256
|
+
repos_for_project(project)[surface.to_s]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Map a surface (within a project) to a local path and optional org/repo.
|
|
260
|
+
def set_repo(project, surface, path:, repo: nil)
|
|
261
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
262
|
+
ensure_config_file!
|
|
263
|
+
|
|
264
|
+
config = load_config
|
|
265
|
+
config['repos'] ||= {}
|
|
266
|
+
config['repos'][project.to_s] ||= {}
|
|
267
|
+
|
|
268
|
+
entry = { 'path' => path }
|
|
269
|
+
entry['repo'] = repo if repo && !repo.to_s.empty?
|
|
270
|
+
config['repos'][project.to_s][surface.to_s] = entry
|
|
271
|
+
|
|
272
|
+
write_config!(config)
|
|
273
|
+
entry
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Remove a surface mapping within a project. Prunes empty project hashes.
|
|
277
|
+
# Returns true if something was removed, false otherwise.
|
|
278
|
+
def unset_repo(project, surface)
|
|
279
|
+
config = load_config
|
|
280
|
+
return false unless config['repos'].is_a?(Hash)
|
|
281
|
+
|
|
282
|
+
project_map = config['repos'][project.to_s]
|
|
283
|
+
return false unless project_map.is_a?(Hash) && project_map.key?(surface.to_s)
|
|
284
|
+
|
|
285
|
+
project_map.delete(surface.to_s)
|
|
286
|
+
config['repos'].delete(project.to_s) if project_map.empty?
|
|
287
|
+
config.delete('repos') if config['repos'].empty?
|
|
288
|
+
|
|
289
|
+
write_config!(config)
|
|
290
|
+
true
|
|
291
|
+
end
|
|
292
|
+
|
|
102
293
|
private
|
|
103
294
|
|
|
104
295
|
def ensure_config_file!
|
|
@@ -117,42 +308,85 @@ module Fp
|
|
|
117
308
|
private
|
|
118
309
|
|
|
119
310
|
def resolve_profile_name(explicit_profile)
|
|
120
|
-
|
|
311
|
+
# 1. Explicit --profile flag 2. FP_PROFILE env var
|
|
312
|
+
if explicit_profile && !explicit_profile.empty?
|
|
313
|
+
@profile_source = 'flag'
|
|
314
|
+
return explicit_profile
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
if ENV['FP_PROFILE'] && !ENV['FP_PROFILE'].empty?
|
|
318
|
+
@profile_source = 'env (FP_PROFILE)'
|
|
319
|
+
return ENV['FP_PROFILE']
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# 3. Fall back to a profile literally named "default", if one exists.
|
|
323
|
+
# This mirrors AWS/git/gcloud, which all consult a default profile when
|
|
324
|
+
# nothing else is specified.
|
|
325
|
+
if self.class.profile_exists?(DEFAULT_PROFILE_NAME)
|
|
326
|
+
@profile_source = 'default profile'
|
|
327
|
+
return DEFAULT_PROFILE_NAME
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
@profile_source = 'none'
|
|
331
|
+
nil
|
|
121
332
|
end
|
|
122
333
|
|
|
123
334
|
def resolve_api_key
|
|
124
335
|
# FP_API_KEY env var always wins
|
|
125
|
-
|
|
336
|
+
if ENV['FP_API_KEY'] && !ENV['FP_API_KEY'].empty?
|
|
337
|
+
@api_key_source = 'env (FP_API_KEY)'
|
|
338
|
+
return ENV['FP_API_KEY']
|
|
339
|
+
end
|
|
126
340
|
|
|
127
341
|
# Otherwise, use profile if specified
|
|
128
|
-
|
|
342
|
+
unless @profile_name
|
|
343
|
+
@api_key_source = 'none'
|
|
344
|
+
return nil
|
|
345
|
+
end
|
|
129
346
|
|
|
130
347
|
profiles = self.class.load_profiles
|
|
131
348
|
profile = profiles[@profile_name]
|
|
132
|
-
|
|
349
|
+
unless profile
|
|
350
|
+
@api_key_source = 'none'
|
|
351
|
+
return nil
|
|
352
|
+
end
|
|
133
353
|
|
|
354
|
+
@api_key_source = "profile (#{@profile_name})"
|
|
134
355
|
profile['api_key']
|
|
135
356
|
end
|
|
136
357
|
|
|
137
358
|
def resolve_api_url
|
|
138
359
|
# 1. Explicit --api-url flag
|
|
139
|
-
|
|
360
|
+
if @explicit_api_url && !@explicit_api_url.empty?
|
|
361
|
+
@api_url_source = 'flag'
|
|
362
|
+
return @explicit_api_url
|
|
363
|
+
end
|
|
140
364
|
|
|
141
365
|
# 2. FP_API_URL env var
|
|
142
|
-
|
|
366
|
+
if ENV['FP_API_URL'] && !ENV['FP_API_URL'].empty?
|
|
367
|
+
@api_url_source = 'env (FP_API_URL)'
|
|
368
|
+
return ENV['FP_API_URL']
|
|
369
|
+
end
|
|
143
370
|
|
|
144
371
|
# 3. Profile-specific URL
|
|
145
372
|
if @profile_name
|
|
146
373
|
profiles = self.class.load_profiles
|
|
147
374
|
profile = profiles[@profile_name]
|
|
148
|
-
|
|
375
|
+
if profile && profile['api_url']
|
|
376
|
+
@api_url_source = "profile (#{@profile_name})"
|
|
377
|
+
return profile['api_url']
|
|
378
|
+
end
|
|
149
379
|
end
|
|
150
380
|
|
|
151
381
|
# 4. Global api_url setting
|
|
152
382
|
global_url = self.class.get_setting('api_url')
|
|
153
|
-
|
|
383
|
+
if global_url && !global_url.empty?
|
|
384
|
+
@api_url_source = 'global setting'
|
|
385
|
+
return global_url
|
|
386
|
+
end
|
|
154
387
|
|
|
155
388
|
# 5. Default
|
|
389
|
+
@api_url_source = 'default'
|
|
156
390
|
DEFAULT_API_URL
|
|
157
391
|
end
|
|
158
392
|
end
|
data/lib/fp/junit.rb
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rexml/document'
|
|
4
|
+
|
|
5
|
+
module Fp
|
|
6
|
+
# Parses JUnit XML reports and binds testcases to fp:<slug> markers found in
|
|
7
|
+
# the referenced source files.
|
|
8
|
+
#
|
|
9
|
+
# This is what powers "upload evidence without CI": an agent runs the test
|
|
10
|
+
# suite locally, produces a JUnit XML report, and `fp report --junit` walks
|
|
11
|
+
# the results, discovers the fp:<slug> markers in the test files, and uploads
|
|
12
|
+
# evidence for each requirement the suite covered.
|
|
13
|
+
module JUnit
|
|
14
|
+
# A single parsed testcase.
|
|
15
|
+
TestCase = Struct.new(:name, :classname, :file, :line, :status, keyword_init: true) do
|
|
16
|
+
# Agents only ever report present/stub — never passing/failing (that's CI).
|
|
17
|
+
# A skipped test is treated as a stub; anything else counts as present.
|
|
18
|
+
def stub?
|
|
19
|
+
status == :skipped
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Evidence discovered by binding a testcase to a marker.
|
|
24
|
+
Binding = Struct.new(:slug, :surface, :file, :title, :state, keyword_init: true)
|
|
25
|
+
|
|
26
|
+
# Matches `fp:<slug>` inside a comment, with an optional `@surface` (or
|
|
27
|
+
# `@surface1,surface2`) suffix to target one or more specific surfaces:
|
|
28
|
+
#
|
|
29
|
+
# fp:print_qr # uses the --surface flag as the default
|
|
30
|
+
# fp:print_qr@api # reports evidence for the `api` surface
|
|
31
|
+
# fp:print_qr@api,web # reports for both `api` and `web`
|
|
32
|
+
#
|
|
33
|
+
# Slugs and surfaces are lowercase letters, digits, underscores and hyphens
|
|
34
|
+
# (matching the propose/report convention).
|
|
35
|
+
MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?/.freeze
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Parse a JUnit XML file into an array of TestCase structs.
|
|
40
|
+
# Raises ArgumentError if the file is missing or unparseable.
|
|
41
|
+
def parse_file(path)
|
|
42
|
+
raise ArgumentError, "JUnit file not found: #{path}" unless File.file?(path)
|
|
43
|
+
|
|
44
|
+
xml = File.read(path)
|
|
45
|
+
parse_string(xml)
|
|
46
|
+
rescue REXML::ParseException => e
|
|
47
|
+
raise ArgumentError, "Could not parse JUnit XML (#{path}): #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_string(xml)
|
|
51
|
+
doc = REXML::Document.new(xml)
|
|
52
|
+
cases = []
|
|
53
|
+
|
|
54
|
+
doc.each_element('//testcase') do |el|
|
|
55
|
+
cases << TestCase.new(
|
|
56
|
+
name: el.attributes['name'],
|
|
57
|
+
classname: el.attributes['classname'],
|
|
58
|
+
file: el.attributes['file'],
|
|
59
|
+
line: (el.attributes['line'] && el.attributes['line'].to_i),
|
|
60
|
+
status: testcase_status(el)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
cases
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Determine present/skipped for a testcase element.
|
|
68
|
+
# Failures/errors still count as "present" for agent evidence — the test
|
|
69
|
+
# exists and was executed. Only skipped/pending tests become stubs.
|
|
70
|
+
def testcase_status(el)
|
|
71
|
+
return :skipped if el.get_elements('skipped').any?
|
|
72
|
+
|
|
73
|
+
:present
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Bind parsed testcases to fp:<slug> markers found in their source files.
|
|
77
|
+
#
|
|
78
|
+
# A marker binds to the test it annotates — the testcase whose line is the
|
|
79
|
+
# smallest line strictly greater than the marker's line (i.e. the next test
|
|
80
|
+
# below the marker in the same file). This mirrors the convention that the
|
|
81
|
+
# `fp:<slug>` comment sits immediately above its test. Tests with no marker
|
|
82
|
+
# directly above them are left unmatched rather than being bound to an
|
|
83
|
+
# unrelated marker.
|
|
84
|
+
#
|
|
85
|
+
# When testcases carry no line information, a file with exactly one marker
|
|
86
|
+
# binds that marker to every testcase in the file (common for small,
|
|
87
|
+
# single-requirement test files); files with multiple markers and no line
|
|
88
|
+
# info can't be disambiguated and are reported as unmatched.
|
|
89
|
+
#
|
|
90
|
+
# A marker may pin one or more surfaces via `@surface` / `@a,b` (e.g. a
|
|
91
|
+
# backend test that satisfies several surfaces). Each surface yields its own
|
|
92
|
+
# binding. Markers without a surface fall back to `default_surface`.
|
|
93
|
+
#
|
|
94
|
+
# base_dir: directory to resolve relative testcase file paths against.
|
|
95
|
+
# default_surface: surface applied to markers that don't pin their own.
|
|
96
|
+
#
|
|
97
|
+
# Returns a hash:
|
|
98
|
+
# {
|
|
99
|
+
# bindings: [Binding, ...], # unique (slug, surface, file) evidence
|
|
100
|
+
# unmatched: [TestCase, ...], # testcases with no discoverable marker
|
|
101
|
+
# missing_surface: [Binding-ish], # markers with no surface and no default
|
|
102
|
+
# }
|
|
103
|
+
def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil)
|
|
104
|
+
marker_cache = {}
|
|
105
|
+
bindings = {}
|
|
106
|
+
matched = {}
|
|
107
|
+
missing_surface = []
|
|
108
|
+
|
|
109
|
+
# Group testcases by their source file so we can reason about ordering.
|
|
110
|
+
by_file = testcases.group_by(&:file)
|
|
111
|
+
|
|
112
|
+
by_file.each do |file, cases|
|
|
113
|
+
markers = markers_for(cases.first, base_dir, marker_cache)
|
|
114
|
+
next if markers.empty?
|
|
115
|
+
|
|
116
|
+
cases.each do |tc|
|
|
117
|
+
marker = marker_for(tc, cases, markers)
|
|
118
|
+
next unless marker
|
|
119
|
+
|
|
120
|
+
matched[tc.object_id] = true
|
|
121
|
+
|
|
122
|
+
surfaces = marker[:surfaces]
|
|
123
|
+
surfaces = [default_surface] if surfaces.empty?
|
|
124
|
+
|
|
125
|
+
surfaces.each do |surface|
|
|
126
|
+
if surface.nil? || surface.empty?
|
|
127
|
+
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
128
|
+
else
|
|
129
|
+
record_binding(bindings, marker[:slug], surface, tc)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
unmatched = testcases.reject { |tc| matched[tc.object_id] }
|
|
136
|
+
{ bindings: bindings.values, unmatched: unmatched, missing_surface: missing_surface.uniq }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Determine the marker (slug + surfaces) that annotates a testcase, or nil.
|
|
140
|
+
def marker_for(testcase, sibling_cases, markers)
|
|
141
|
+
if testcase.line
|
|
142
|
+
marker_for_line(testcase.line, sibling_cases, markers)
|
|
143
|
+
elsif markers.length == 1 && sibling_cases.none?(&:line)
|
|
144
|
+
# No line info anywhere and a single marker: unambiguous.
|
|
145
|
+
markers.first
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A marker annotates the testcase that is the first test below it. Given a
|
|
150
|
+
# testcase line, find the marker that sits directly above it with no other
|
|
151
|
+
# testcase in between.
|
|
152
|
+
def marker_for_line(line, sibling_cases, markers)
|
|
153
|
+
candidate = markers.select { |m| m[:line] < line }.max_by { |m| m[:line] }
|
|
154
|
+
return nil unless candidate
|
|
155
|
+
|
|
156
|
+
# Reject if another testcase falls between the marker and this test —
|
|
157
|
+
# that means the marker belongs to the intervening test, not this one.
|
|
158
|
+
intervening = sibling_cases.any? do |other|
|
|
159
|
+
other.line && other.line > candidate[:line] && other.line < line
|
|
160
|
+
end
|
|
161
|
+
return nil if intervening
|
|
162
|
+
|
|
163
|
+
candidate
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def record_binding(bindings, slug, surface, testcase)
|
|
167
|
+
rel_file = testcase.file
|
|
168
|
+
key = [slug, surface, rel_file]
|
|
169
|
+
state = testcase.stub? ? 'stub' : 'present'
|
|
170
|
+
|
|
171
|
+
existing = bindings[key]
|
|
172
|
+
# Prefer a concrete 'present' state over 'stub' when a slug has both.
|
|
173
|
+
return if existing && !(existing.state == 'stub' && state == 'present')
|
|
174
|
+
|
|
175
|
+
bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file,
|
|
176
|
+
title: testcase.name, state: state)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Load and cache the fp:<slug> markers (with line numbers) for a testcase's
|
|
180
|
+
# source file. Returns [] when the file is unknown or unreadable.
|
|
181
|
+
def markers_for(testcase, base_dir, cache)
|
|
182
|
+
file = testcase.file
|
|
183
|
+
return [] if file.nil? || file.empty?
|
|
184
|
+
|
|
185
|
+
return cache[file] if cache.key?(file)
|
|
186
|
+
|
|
187
|
+
resolved = File.expand_path(file, base_dir)
|
|
188
|
+
markers =
|
|
189
|
+
if File.file?(resolved)
|
|
190
|
+
scan_markers(File.read(resolved))
|
|
191
|
+
else
|
|
192
|
+
[]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
cache[file] = markers
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
|
|
199
|
+
# [{ slug:, surfaces: [..], line: }, ...] (1-based line numbers).
|
|
200
|
+
# `surfaces` is [] when the marker pins none (use the default surface).
|
|
201
|
+
def scan_markers(contents)
|
|
202
|
+
markers = []
|
|
203
|
+
contents.each_line.with_index(1) do |line, num|
|
|
204
|
+
line.scan(MARKER_RE) do |(slug, surface_list)|
|
|
205
|
+
surfaces = surface_list ? surface_list.split(',').map(&:strip).reject(&:empty?) : []
|
|
206
|
+
markers << { slug: slug, surfaces: surfaces, line: num }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
markers
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
data/lib/fp/version.rb
CHANGED