featurevisor 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/bin/cli.rb CHANGED
@@ -7,7 +7,9 @@ module FeaturevisorCLI
7
7
  attr_accessor :command, :assertion_pattern, :context, :environment, :feature,
8
8
  :key_pattern, :n, :only_failures, :quiet, :variable, :variation,
9
9
  :verbose, :inflate, :show_datafile, :schema_version, :project_directory_path,
10
- :populate_uuid, :with_scopes, :with_tags, :targets
10
+ :populate_uuid, :with_scopes, :with_tags, :targets, :entity_type, :set,
11
+ :json, :pretty, :case_id, :coverage, :uncovered,
12
+ :project_directory_path_explicit
11
13
 
12
14
  def initialize
13
15
  @n = 1000
@@ -26,12 +28,15 @@ module FeaturevisorCLI
26
28
  end
27
29
 
28
30
  options.command = args[0]
31
+ unless %w[test benchmark assess-distribution].include?(options.command)
32
+ raise ArgumentError, "Unknown command: #{options.command}"
33
+ end
29
34
  remaining_args = args[1..-1]
30
35
 
31
36
  OptionParser.new do |opts|
32
- opts.banner = "Usage: featurevisor [command] [options]"
37
+ opts.banner = "Usage: featurevisor #{options.command} [options]"
33
38
 
34
- opts.on("--assertionPattern=PATTERN", "Assertion pattern") do |v|
39
+ opts.on("--assertion-pattern=PATTERN", "--assertionPattern=PATTERN", "Assertion pattern") do |v|
35
40
  options.assertion_pattern = v
36
41
  end
37
42
 
@@ -39,23 +44,31 @@ module FeaturevisorCLI
39
44
  options.context = v
40
45
  end
41
46
 
42
- opts.on("--environment=ENV", "Environment (required for benchmark)") do |v|
47
+ opts.on("--environment=ENV", "Configured environment (omit for flat projects)") do |v|
43
48
  options.environment = v
44
49
  end
45
50
 
46
51
  opts.on("--feature=FEATURE", "Feature key") do |v|
47
- options.feature = v
52
+ options.feature = nonempty_value("--feature", v)
48
53
  end
49
54
 
50
- opts.on("--keyPattern=PATTERN", "Key pattern") do |v|
55
+ opts.on("--key-pattern=PATTERN", "--keyPattern=PATTERN", "Key pattern") do |v|
51
56
  options.key_pattern = v
52
57
  end
53
58
 
54
- opts.on("-n", "--iterations=N", "--n=N", Integer, "Number of iterations (default: 1000)") do |v|
55
- options.n = v
59
+ opts.on("--entity-type=TYPE", "--entityType=TYPE", %w[feature segment variable scenario], "Filter tests by entity type") do |v|
60
+ options.entity_type = v
61
+ end
62
+
63
+ opts.on("--iterations=N", "--n=N", "Number of iterations (default: 1000)") do |v|
64
+ options.n = positive_iterations(v)
56
65
  end
57
66
 
58
- opts.on("--onlyFailures", "Only show failures") do
67
+ opts.on("-n N", "Number of iterations (default: 1000)") do |v|
68
+ options.n = positive_iterations(v.delete_prefix("="))
69
+ end
70
+
71
+ opts.on("--only-failures", "--onlyFailures", "Only show failures") do
59
72
  options.only_failures = true
60
73
  end
61
74
 
@@ -64,7 +77,7 @@ module FeaturevisorCLI
64
77
  end
65
78
 
66
79
  opts.on("--variable=VARIABLE", "Variable key") do |v|
67
- options.variable = v
80
+ options.variable = nonempty_value("--variable", v)
68
81
  end
69
82
 
70
83
  opts.on("--variation", "Variation mode") do
@@ -76,10 +89,11 @@ module FeaturevisorCLI
76
89
  end
77
90
 
78
91
  opts.on("--inflate=N", Integer, "Inflate mode") do |v|
92
+ raise OptionParser::InvalidArgument, "--inflate must be a positive integer" unless v.positive?
79
93
  options.inflate = v
80
94
  end
81
95
 
82
- opts.on("--showDatafile", "Show datafile content for each test") do
96
+ opts.on("--show-datafile", "--showDatafile", "Show datafile content for each test") do
83
97
  options.show_datafile = true
84
98
  end
85
99
 
@@ -95,29 +109,79 @@ module FeaturevisorCLI
95
109
  options.with_tags = true
96
110
  end
97
111
 
98
- opts.on("--projectDirectoryPath=PATH", "Project directory path") do |v|
99
- options.project_directory_path = v
112
+ opts.on("--project-directory-path=PATH", "--projectDirectoryPath=PATH", "--root-directory-path=PATH", "--rootDirectoryPath=PATH", "Project directory path") do |v|
113
+ options.project_directory_path = nonempty_value("--project-directory-path", v)
114
+ options.project_directory_path_explicit = true
100
115
  end
101
116
 
102
- opts.on("--populateUuid=KEY", "Populate UUID for attribute key") do |v|
103
- options.populate_uuid << v
117
+ opts.on("--populate-uuid=KEY", "--populateUuid=KEY", "Populate UUID for attribute key") do |v|
118
+ options.populate_uuid << nonempty_value("--populate-uuid", v)
104
119
  end
105
120
 
106
121
  opts.on("--target=TARGET", "Target datafile; repeat for multiple targets") do |v|
122
+ v = nonempty_value("--target", v)
107
123
  options.targets << v unless options.targets.include?(v)
108
124
  end
109
125
 
126
+ opts.on("--set=SET", "Project set") do |v|
127
+ options.set = v
128
+ end
129
+
130
+ opts.on("--json", "Print a versioned JSON test report") do
131
+ options.json = true
132
+ end
133
+
134
+ opts.on("--pretty", "Pretty print JSON output") do
135
+ options.pretty = true
136
+ end
137
+
138
+ opts.on("--case=ID", "Run one exact expanded test case") do |v|
139
+ options.case_id = v
140
+ end
141
+
142
+ opts.on("--coverage", "Report observed rule and override selections") do
143
+ options.coverage = true
144
+ end
145
+
146
+ opts.on("--uncovered", "List features and variables without associated tests") do
147
+ options.uncovered = true
148
+ end
149
+
110
150
  opts.on("-h", "--help", "Show this help message") do
111
151
  puts opts
112
152
  exit
113
153
  end
114
154
  end.parse!(remaining_args)
115
155
 
156
+ unless remaining_args.empty?
157
+ raise OptionParser::InvalidArgument, "Unexpected arguments: #{remaining_args.join(' ')}"
158
+ end
159
+ unless options.context.nil?
160
+ context = JSON.parse(options.context)
161
+ raise ArgumentError, "--context must be a JSON object" unless context.is_a?(Hash)
162
+ end
116
163
  options
117
164
  end
165
+
166
+ def self.positive_iterations(value)
167
+ parsed = Integer(value, 10)
168
+ raise ArgumentError unless parsed.positive?
169
+ parsed
170
+ rescue ArgumentError
171
+ raise OptionParser::InvalidArgument, "--n must be a positive integer"
172
+ end
173
+
174
+ def self.nonempty_value(name, value)
175
+ if value.empty? || value.start_with?("--")
176
+ raise OptionParser::InvalidArgument, "#{name} requires a nonempty value"
177
+ end
178
+ value
179
+ end
118
180
  end
119
181
 
120
182
  def self.run(args)
183
+ return show_help if args.empty? || args == ["--help"] || args == ["-h"]
184
+
121
185
  options = Parser.parse(args)
122
186
 
123
187
  case options.command
@@ -130,6 +194,9 @@ module FeaturevisorCLI
130
194
  else
131
195
  show_help
132
196
  end
197
+ rescue ArgumentError, OptionParser::ParseError, JSON::ParserError, SystemCallError => e
198
+ warn "Error: #{e.message}"
199
+ exit 1
133
200
  end
134
201
 
135
202
  def self.show_help
@@ -146,10 +213,10 @@ module FeaturevisorCLI
146
213
  puts ""
147
214
  puts "Examples:"
148
215
  puts " featurevisor test"
149
- puts " featurevisor test --keyPattern=pattern"
216
+ puts " featurevisor test --key-pattern=pattern"
150
217
  puts " featurevisor benchmark --feature=myFeature --environment=dev --n=10000"
151
218
  puts " featurevisor assess-distribution --feature=myFeature --n=10000"
152
219
  puts ""
153
- puts "Note: benchmark requires --environment and either --feature or --variable"
220
+ puts "Note: benchmark requires --feature or --variable; --environment is required only when configured"
154
221
  end
155
222
  end
@@ -1,10 +1,12 @@
1
1
  require "json"
2
2
  require "securerandom"
3
3
  require "open3"
4
+ require_relative "runtime_project"
4
5
 
5
6
  module FeaturevisorCLI
6
7
  module Commands
7
8
  class AssessDistribution
9
+ include RuntimeProject
8
10
  # UUID_LENGTHS matches the TypeScript implementation
9
11
  UUID_LENGTHS = [4, 2, 2, 2, 6]
10
12
 
@@ -19,11 +21,6 @@ module FeaturevisorCLI
19
21
 
20
22
  def run
21
23
  # Validate required options
22
- unless @options.environment
23
- puts "Error: --environment is required for assess-distribution command"
24
- exit 1
25
- end
26
-
27
24
  unless @options.feature
28
25
  puts "Error: --feature is required for assess-distribution command"
29
26
  exit 1
@@ -31,8 +28,10 @@ module FeaturevisorCLI
31
28
 
32
29
  context = parse_context
33
30
 
34
- targets = resolve_targets
35
- (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) }
31
+ each_project do
32
+ targets = resolve_targets
33
+ (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) }
34
+ end
36
35
  end
37
36
 
38
37
  private
@@ -40,7 +39,7 @@ module FeaturevisorCLI
40
39
  def run_for_target(context, target)
41
40
  puts "\nAssess Featurevisor distribution"
42
41
  puts " Feature: #{@options.feature}"
43
- puts " Environment: #{@options.environment}"
42
+ puts " Environment: #{@options.environment}" if @options.environment
44
43
  puts " Target: #{target}" if target
45
44
  puts " Iterations: #{@options.n}"
46
45
 
@@ -112,26 +111,12 @@ module FeaturevisorCLI
112
111
  end
113
112
  end
114
113
 
115
- def resolve_targets
116
- return [] if @options.targets.empty?
117
- stdout, stderr, status = Open3.capture3("npx", "featurevisor", "list", "--targets", "--json", chdir: @project_path)
118
- unless status.success?
119
- puts stderr
120
- exit 1
121
- end
122
- available = JSON.parse(stdout).map { |item| item.is_a?(Hash) ? (item["key"] || item["name"]) : item }
123
- unknown = @options.targets.find { |target| !available.include?(target) }
124
- if unknown
125
- puts "Unknown target \"#{unknown}\". Available targets: #{available.empty? ? "none" : available.join(", ")}."
126
- exit 1
127
- end
128
- @options.targets
129
- end
130
114
 
131
115
  def parse_context
132
116
  if @options.context
133
117
  begin
134
118
  context = JSON.parse(@options.context)
119
+ raise ArgumentError, "--context must be a JSON object" unless context.is_a?(Hash)
135
120
  # Convert string keys to symbols for the SDK
136
121
  context.transform_keys(&:to_sym)
137
122
  rescue JSON::ParserError => e
@@ -143,38 +128,11 @@ module FeaturevisorCLI
143
128
  end
144
129
  end
145
130
 
146
- def build_datafile(environment, target = nil)
147
- puts "Building datafile for environment: #{environment}..."
148
-
149
- # Build the command similar to Go implementation
150
- command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"]
151
- command_parts << "--target=#{target}" if target
152
-
153
- if @options.inflate
154
- command_parts << "--inflate=#{@options.inflate}"
155
- end
156
-
157
- command = command_parts.join(" ")
158
-
159
- stdout, stderr, exit_status = execute_command(command)
160
-
161
- if exit_status != 0
162
- puts "Error: Command failed with exit code #{exit_status}"
163
- puts "Command: #{command}"
164
- puts "Stderr: #{stderr}"
165
- exit 1
166
- end
167
-
168
- begin
169
- JSON.parse(stdout, symbolize_names: true)
170
- rescue JSON::ParserError => e
171
- puts "Error: Failed to parse datafile JSON: #{e.message}"
172
- exit 1
173
- end
174
- end
175
131
 
176
132
  def execute_command(command)
177
133
  stdout, stderr, exit_status = Open3.capture3(command)
134
+ stdout.force_encoding(Encoding::UTF_8)
135
+ stderr.force_encoding(Encoding::UTF_8)
178
136
  [stdout, stderr, exit_status.exitstatus]
179
137
  end
180
138
 
@@ -2,10 +2,12 @@ require "benchmark"
2
2
  require "json"
3
3
  require "time"
4
4
  require "open3"
5
+ require_relative "runtime_project"
5
6
 
6
7
  module FeaturevisorCLI
7
8
  module Commands
8
9
  class Benchmark
10
+ include RuntimeProject
9
11
  def self.run(options)
10
12
  new(options).run
11
13
  end
@@ -17,19 +19,16 @@ module FeaturevisorCLI
17
19
 
18
20
  def run
19
21
  # Validate required options
20
- unless @options.environment
21
- puts "Error: --environment is required for benchmark command"
22
- exit 1
23
- end
24
-
25
22
  unless @options.feature || @options.variable
26
23
  puts "Error: --feature or --variable is required for benchmark command"
27
24
  exit 1
28
25
  end
29
26
 
30
27
  context = parse_context
31
- targets = resolve_targets
32
- (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) }
28
+ each_project do
29
+ targets = resolve_targets
30
+ (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) }
31
+ end
33
32
  end
34
33
 
35
34
  private
@@ -43,7 +42,7 @@ module FeaturevisorCLI
43
42
  puts "\nBenchmark Featurevisor feature"
44
43
  puts " Feature: #{@options.feature}" if @options.feature
45
44
  puts " Global variable: #{@options.variable}" if @options.variable && !@options.feature
46
- puts " Environment: #{@options.environment}"
45
+ puts " Environment: #{@options.environment}" if @options.environment
47
46
  puts " Target: #{target}" if target
48
47
  puts " Iterations: #{@options.n}"
49
48
  puts " Build duration: #{datafile_build_duration_ms}ms"
@@ -85,26 +84,12 @@ module FeaturevisorCLI
85
84
  puts "Maximum duration: #{format_duration_ms(output[:max_duration])}"
86
85
  end
87
86
 
88
- def resolve_targets
89
- return [] if @options.targets.empty?
90
- stdout, stderr, status = Open3.capture3("npx", "featurevisor", "list", "--targets", "--json", chdir: @project_path)
91
- unless status.success?
92
- puts stderr
93
- exit 1
94
- end
95
- available = JSON.parse(stdout).map { |target| target.is_a?(Hash) ? (target["key"] || target["name"]) : target }
96
- unknown = @options.targets.find { |target| !available.include?(target) }
97
- if unknown
98
- puts "Unknown target \"#{unknown}\". Available targets: #{available.empty? ? "none" : available.join(", ")}."
99
- exit 1
100
- end
101
- @options.targets
102
- end
103
87
 
104
88
  def parse_context
105
89
  if @options.context
106
90
  begin
107
91
  context = JSON.parse(@options.context)
92
+ raise ArgumentError, "--context must be a JSON object" unless context.is_a?(Hash)
108
93
  # Convert string keys to symbols for the SDK
109
94
  context.transform_keys(&:to_sym)
110
95
  rescue JSON::ParserError => e
@@ -116,35 +101,12 @@ module FeaturevisorCLI
116
101
  end
117
102
  end
118
103
 
119
- def build_datafile(environment, target = nil)
120
- puts "Building datafile for environment: #{environment}..."
121
-
122
- command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"]
123
- command_parts << "--target=#{target}" if target
124
-
125
- # Add inflate if specified
126
- if @options.inflate && @options.inflate > 0
127
- command_parts << "--inflate=#{@options.inflate}"
128
- end
129
-
130
- command = command_parts.join(" ")
131
-
132
- # Execute the command and capture output
133
- datafile_output = execute_command(command)
134
-
135
- # Parse the JSON output
136
- begin
137
- JSON.parse(datafile_output, symbolize_names: true)
138
- rescue JSON::ParserError => e
139
- puts "Error: Failed to parse datafile JSON: #{e.message}"
140
- puts "Command output: #{datafile_output}"
141
- exit 1
142
- end
143
- end
144
104
 
145
105
  def execute_command(command)
146
106
  # Execute the command and capture stdout/stderr
147
107
  stdout, stderr, status = Open3.capture3(command)
108
+ stdout.force_encoding(Encoding::UTF_8)
109
+ stderr.force_encoding(Encoding::UTF_8)
148
110
 
149
111
  unless status.success?
150
112
  puts "Error: Command failed with exit code #{status.exitstatus}"
@@ -0,0 +1,20 @@
1
+ module FeaturevisorCLI
2
+ # Runs the project's own Node based Featurevisor CLI.
3
+ #
4
+ # Node starts the local package entry directly, with an argument list and a
5
+ # working directory, so no shell quoting is involved on any platform. Without a
6
+ # local package, npx resolves the CLI as before, through npx.cmd on Windows.
7
+ module NodeCli
8
+ def self.command(project_path)
9
+ directory = File.expand_path(project_path)
10
+ loop do
11
+ entry = File.join(directory, "node_modules", "@featurevisor", "cli", "bin.js")
12
+ return ["node", entry] if File.file?(entry)
13
+ parent = File.dirname(directory)
14
+ break if parent == directory
15
+ directory = parent
16
+ end
17
+ [Gem.win_platform? ? "npx.cmd" : "npx", "featurevisor"]
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,63 @@
1
+ require "json"
2
+ require "open3"
3
+
4
+ module FeaturevisorCLI
5
+ module Commands
6
+ module RuntimeProject
7
+ def each_project
8
+ raise ArgumentError, "--n must be a positive integer" unless @options.n.positive?
9
+ if @options.variation && @options.variable
10
+ raise ArgumentError, "--variation and --variable cannot be combined"
11
+ end
12
+ config = project_json("config", "--json", include_set: false)
13
+ environments = config["environments"]
14
+ if environments.is_a?(Array)
15
+ raise ArgumentError, "Pass --environment=<environment> when selecting one datafile." unless @options.environment
16
+ unless environments.include?(@options.environment)
17
+ raise ArgumentError, "Unknown environment #{@options.environment.inspect}. Available environments: #{environments.join(', ')}."
18
+ end
19
+ elsif @options.environment
20
+ raise ArgumentError, "This project does not configure environments. Omit --environment."
21
+ end
22
+ sets = [@options.set]
23
+ if config["sets"] && !@options.set
24
+ directory = File.expand_path(config["setsDirectoryPath"] || "sets", @project_path)
25
+ sets = Dir.children(directory).select { |entry| File.directory?(File.join(directory, entry)) }.sort
26
+ raise ArgumentError, "No sets found in #{directory}." if sets.empty?
27
+ end
28
+ sets.each do |set|
29
+ @active_set = set
30
+ puts "Set #{set.inspect}" if set
31
+ yield
32
+ end
33
+ end
34
+
35
+ def project_json(*args, include_set: true)
36
+ args << "--set=#{@active_set || @options.set}" if include_set && (@active_set || @options.set)
37
+ stdout, stderr, status = Open3.capture3(*FeaturevisorCLI::NodeCli.command(@project_path), *args, chdir: @project_path)
38
+ stdout.force_encoding(Encoding::UTF_8)
39
+ stderr.force_encoding(Encoding::UTF_8)
40
+ raise ArgumentError, stderr.empty? ? "Node CLI failed with exit code #{status.exitstatus}" : stderr unless status.success?
41
+ warn stderr unless stderr.empty?
42
+ JSON.parse(stdout)
43
+ end
44
+
45
+ def resolve_targets
46
+ return [] if @options.targets.empty?
47
+ available = project_json("list", "--targets", "--json").map { |target| target.is_a?(Hash) ? (target["key"] || target["name"]) : target }
48
+ unknown = @options.targets.find { |target| !available.include?(target) }
49
+ raise ArgumentError, "Unknown target #{unknown.inspect}. Available targets: #{available.join(', ')}." if unknown
50
+ @options.targets
51
+ end
52
+
53
+ def build_datafile(environment, target = nil)
54
+ args = ["build", "--json"]
55
+ args << "--environment=#{environment}" if environment
56
+ args << "--target=#{target}" if target
57
+ args << "--inflate=#{@options.inflate}" if @options.inflate && @options.inflate.positive?
58
+ # Symbol keys are the native SDK datafile representation.
59
+ JSON.parse(JSON.generate(project_json(*args)), symbolize_names: true)
60
+ end
61
+ end
62
+ end
63
+ end