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.
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
- explicit_profile || ENV['FP_PROFILE']
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
- return ENV['FP_API_KEY'] if ENV['FP_API_KEY'] && !ENV['FP_API_KEY'].empty?
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
- return nil unless @profile_name
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
- return nil unless profile
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
- return @explicit_api_url if @explicit_api_url && !@explicit_api_url.empty?
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
- return ENV['FP_API_URL'] if ENV['FP_API_URL'] && !ENV['FP_API_URL'].empty?
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
- return profile['api_url'] if profile && profile['api_url']
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
- return global_url if global_url && !global_url.empty?
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.2'
4
+ VERSION = '0.0.4'
5
5
  end
data/lib/fp.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative 'fp/version'
4
4
  require_relative 'fp/config'
5
5
  require_relative 'fp/client'
6
+ require_relative 'fp/junit'
6
7
  require_relative 'fp/output'
7
8
  require_relative 'fp/commands'
8
9
  require_relative 'fp/cli'