Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Composer installs the archive Git exports. Keep tests, fixtures and
# contributor tooling out of it. The featurevisor CLI and cli/ ship.
/.editorconfig export-ignore
/.github export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.nvmrc export-ignore
/.prettierrc export-ignore
/CODE_OF_CONDUCT.md export-ignore
/CONTRIBUTING.md export-ignore
/Makefile export-ignore
/conformance export-ignore
/phpstan.neon export-ignore
/phpunit.xml export-ignore
/scripts export-ignore
/tests export-ignore
4 changes: 2 additions & 2 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ jobs:
composer config minimum-stability dev
composer config prefer-stable true
composer config repositories.featurevisor "{\"type\":\"path\",\"url\":\"$GITHUB_WORKSPACE\",\"options\":{\"symlink\":false}}"
composer require featurevisor/featurevisor-php:@dev open-feature/sdk:^2.2 --no-interaction --prefer-dist --no-progress
composer require featurevisor/featurevisor-php:@dev open-feature/sdk:^2.3 --no-interaction --prefer-dist --no-progress
php -r 'require "vendor/autoload.php"; $p = new Featurevisor\OpenFeatureProvider(["logLevel" => "fatal"]); if ($p->getMetadata()->getName() !== "Featurevisor") { exit(1); } $p->shutdown();'

example-project:
Expand Down Expand Up @@ -167,7 +167,7 @@ jobs:
- name: Set up Featurevisor example-1 project
run: |
mkdir example-1
(cd example-1 && npx --yes @featurevisor/cli@3 init --example=1)
(cd example-1 && npx --yes @featurevisor/cli@latest init --example=1)
(cd example-1 && npm install)
(cd example-1 && npx featurevisor build)
(cd example-1 && npx featurevisor test --onlyFailures)
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/packagist.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ jobs:
- name: Validate release tag
shell: bash
run: |
if [[ ! "$GITHUB_REF_NAME" =~ ^v3\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Expected a Featurevisor PHP v3 semantic version tag such as v3.0.0"
if [[ ! "$GITHUB_REF_NAME" =~ ^v4\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "Expected a Featurevisor PHP v4 semantic version tag such as v4.0.0"
exit 1
fi

Expand Down
437 changes: 278 additions & 159 deletions README.md

Large diffs are not rendered by default.

141 changes: 141 additions & 0 deletions cli/Options.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

/** Parse native runner arguments without interpreting their contents as shell text. */
function cliOptionTypes(): array {
return [
'assertionPattern' => 'string', 'entityType' => 'string', 'context' => 'string',
'environment' => 'string', 'feature' => 'string', 'keyPattern' => 'string',
'n' => 'string', 'onlyFailures' => 'boolean', 'quiet' => 'boolean',
'showDatafile' => 'boolean', 'variable' => 'string', 'variation' => 'boolean',
'verbose' => 'boolean', 'inflate' => 'string', 'withScopes' => 'boolean',
'withTags' => 'boolean', 'schemaVersion' => 'string',
'projectDirectoryPath' => 'string', 'populateUuid' => 'array', 'target' => 'array',
'set' => 'string', 'json' => 'boolean', 'pretty' => 'boolean', 'case' => 'string',
'coverage' => 'boolean', 'uncovered' => 'boolean', 'help' => 'boolean',
];
}

function canonicalCliOption(string $name): string {
$name = preg_replace_callback('/-([a-z])/', static fn(array $match): string => strtoupper($match[1]), $name);
if ($name === 'rootDirectoryPath') return 'projectDirectoryPath';
return $name === 'h' ? 'help' : $name;
}

function parseCliArguments(array $argv): array {
$types = cliOptionTypes();
$values = [];
foreach ($types as $key => $type) $values[$key] = $type === 'array' ? [] : null;
$command = null;
$seen = [];
for ($index = 1; $index < count($argv); $index++) {
$argument = $argv[$index];
if (substr($argument, 0, 1) !== '-') {
if ($command !== null) throw new InvalidArgumentException('Unexpected argument: '.$argument);
$command = $argument;
continue;
}
if (!preg_match('/^(--[a-zA-Z][a-zA-Z-]*|-(n|h))(=|$)/', $argument)) {
throw new InvalidArgumentException('Unknown option: '.$argument);
}
$parts = explode('=', ltrim($argument, '-'), 2);
$key = canonicalCliOption($parts[0]);
if (!isset($types[$key])) throw new InvalidArgumentException('Unknown option: '.$parts[0]);
$type = $types[$key];
if ($type !== 'array' && isset($seen[$key])) {
throw new InvalidArgumentException('Option supplied more than once: --'.$parts[0]);
}
$seen[$key] = true;
if ($type === 'boolean') {
$value = $parts[1] ?? null;
if ($value === null && isset($argv[$index + 1]) && in_array($argv[$index + 1], ['true', 'false'], true)) {
$value = $argv[++$index];
}
if ($value !== null && !in_array($value, ['true', 'false'], true)) {
throw new InvalidArgumentException('--'.$parts[0].' must be true or false');
}
$values[$key] = $value !== 'false';
continue;
}
if (count($parts) === 2) {
$value = $parts[1];
} else {
$value = $argv[$index + 1] ?? null;
if ($value === null || (substr($value, 0, 1) === '-' && !in_array($key, ['n', 'inflate'], true))) {
throw new InvalidArgumentException('--'.$parts[0].' requires a value');
}
if (substr($value, 0, 2) === '--') throw new InvalidArgumentException('--'.$parts[0].' requires a value');
$index++;
}
if ($value === '' && !in_array($key, ['keyPattern', 'assertionPattern', 'schemaVersion'], true)) {
throw new InvalidArgumentException('--'.$parts[0].' requires a nonempty value');
}
if ($type === 'array') $values[$key][] = $value;
else $values[$key] = $value;
}
$values['0'] = $command;
$values['targets'] = array_values(array_unique($values['target']));
unset($values['target']);
$values['rootDirectoryPath'] = $values['projectDirectoryPath'] ?? getcwd();
return $values;
}

function parseCliOption(array $argv, string $key) {
$values = parseCliArguments($argv);
$key = canonicalCliOption($key);
return $values[$key === 'target' ? 'targets' : $key] ?? null;
}

function parseCliOptions(array $argv, string $key): array {
$value = parseCliOption($argv, $key);
return is_array($value) ? $value : ($value === null ? [] : [$value]);
}

function validateCliArguments(array $options): void {
if (!in_array($options['0'], ['test', 'benchmark', 'assess-distribution'], true)) {
throw new InvalidArgumentException('Expected test, benchmark or assess-distribution. Use --help for usage.');
}
if ($options['n'] !== null && (filter_var($options['n'], FILTER_VALIDATE_INT) === false || (int) $options['n'] < 1)) {
throw new InvalidArgumentException('--n must be a positive integer');
}
validateInflateOption($options['inflate']);
if ($options['entityType'] !== null && !in_array($options['entityType'], ['feature', 'segment', 'variable', 'scenario'], true)) {
throw new InvalidArgumentException('--entity-type must be feature, segment, variable or scenario');
}
if ($options['context'] !== null) {
$context = json_decode($options['context'], false, 512, JSON_THROW_ON_ERROR);
if (!$context instanceof stdClass) throw new InvalidArgumentException('--context must be a JSON object');
}
}

function printCliHelp(): void {
echo <<<'HELP'
Usage: featurevisor <test|benchmark|assess-distribution> [options]

Node.js owns project parsing, test expansion and datafile building.
The PHP SDK evaluates the generated inputs.

--project-directory-path PATH Project directory (also --root-directory-path)
--set NAME Select one Set
--target KEY Select a Target, repeat for multiple Targets
--key-pattern PATTERN Filter test keys
--assertion-pattern PATTERN Filter test assertions
--entity-type TYPE feature, segment, variable or scenario
--case ID Select an exact expanded test case
--json --pretty Test report output
--coverage --uncovered Test coverage output
--only-failures --show-datafile Test diagnostics
--quiet --verbose Diagnostic verbosity
--feature KEY --variable KEY Evaluation selectors
--variation Select a variation, not a variable
--environment NAME Required when environments are configured
--context JSON Context object
--populate-uuid KEY Generate a UUID per distribution context attribute, repeatable
--n N, -n N Positive evaluation count (default 1000)
--inflate N Positive datafile inflation factor
--help, -h Show this help

CamelCase option names remain supported. Values accept --name=value or --name value.
Distribution also supports --variation and feature --variable as PHP extensions.

HELP;
}
70 changes: 70 additions & 0 deletions cli/Segments.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace FeaturevisorCLI;

use Featurevisor\Internal\JsonValues;
use Featurevisor\Internal\Normalize;

final class Segments
{
public static function prepare($datafile, array $segments): array
{
if (is_string($datafile)) $datafile = JsonValues::normalizeDatafileValue(json_decode($datafile, false, 512, JSON_THROW_ON_ERROR));
$datafile['segments'] = array_replace($segments, $datafile['segments'] ?? []);
$datafile['_segmentTestKeys'] = [];
foreach ($segments as $key => $_) {
$helper = '__featurevisor_segment_test__'.$key;
while (array_key_exists($helper, $datafile['features'] ?? [])) $helper .= '_';
$datafile['_segmentTestKeys'][$key] = $helper;
$datafile['features'][$helper] = ['bucketBy' => '__segment_test__', 'conditions' => (string) $key,
'traffic' => [['key' => 'segment', 'conditions' => '*', 'percentage' => 100000]]];
}
return $datafile;
}

/** Inspect authored reference closure without invoking SDK getters. */
public static function preflight(string $key, array $datafile, array $segments): void
{
$pending = [['segment', $key, null]];
$seen = [];
$expression = static function ($value) use (&$expression, &$pending): void {
if (is_string($value)) {
if (preg_match('/^\s*[\{\[\"]/', $value) === 1) {
try { $expression(json_decode($value, true, 512, JSON_THROW_ON_ERROR)); } catch (\JsonException $error) {}
} elseif ($value !== '*') $pending[] = ['segment', $value, null];
} elseif (is_array($value)) {
if (isset($value['variable'])) $pending[] = isset($value['feature'])
? ['feature', $value['feature'], $value['variable']] : ['variable', $value['variable'], null];
elseif (isset($value['feature'])) $pending[] = ['feature', $value['feature'], null];
elseif (Normalize::isList($value)) foreach ($value as $child) $expression($child);
else foreach (['and', 'or', 'not'] as $operator) if (isset($value[$operator])) $expression($value[$operator]);
}
};
$selector = static function (array $entry) use ($expression): void { $expression($entry['conditions'] ?? null); };
for ($cursor = 0; $cursor < count($pending); $cursor++) {
[$kind, $name, $variable] = $pending[$cursor];
$identity = json_encode($pending[$cursor], JSON_THROW_ON_ERROR);
if (isset($seen[$identity])) continue;
$seen[$identity] = true;
$definition = $kind === 'segment' ? ($segments[$name] ?? $datafile['segments'][$name] ?? null)
: ($datafile[$kind === 'feature' ? 'features' : 'variables'][$name] ?? null);
if (!is_array($definition)) throw new \RuntimeException($kind.' "'.$name.'" is absent from the selected datafile and test definitions');
if ($variable !== null && !array_key_exists($variable, $definition['variablesSchema'] ?? [])) {
throw new \RuntimeException('variable "'.$variable.'" is absent from feature "'.$name.'" in the selected datafile');
}
[$normalized] = Normalize::entity($kind, $name, $definition);
$selector($normalized);
if ($kind === 'feature') {
foreach (array_merge($normalized['force'] ?? [], $normalized['traffic'] ?? []) as $entry) $selector($entry);
if ($variable !== null) foreach (array_merge($normalized['traffic'] ?? [], $normalized['variations'] ?? []) as $entry) {
foreach ($entry['variableOverrides'][$variable] ?? [] as $override) $selector($override);
}
} elseif ($kind === 'variable') {
foreach (array_merge($normalized['overrides'] ?? [], $normalized['overrideProgram'] ?? []) as $entry) $selector($entry);
$compose = $normalized['compose'] ?? null;
if (is_string($compose)) $pending[] = ['variable', $compose, null];
elseif (is_array($compose)) foreach ($compose as $entry) $expression($entry);
}
}
}
}
Loading
Loading