@quonfig/cli 0.0.50 → 0.0.52

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.
@@ -0,0 +1,703 @@
1
+ /**
2
+ * Flagsmith converter — Flagsmith feature/segment JSON → `QuonfigFile`-ready
3
+ * objects (plain JS objects ready for `JSON.stringify`).
4
+ *
5
+ * Provider-specific topology collapse (plan §5, Epic 3). Calls the shared
6
+ * `quonfig-target/` verb library where the structure is identical to LD
7
+ * (value coercion, criterion + rule shape, report wiring) and handles the
8
+ * Flagsmith-shaped bits here: synthesizing the ordered rule list from
9
+ * `feature_state` + `segment_overrides` + `identity_overrides` (§5.1),
10
+ * `enabled=false` disposition per D-F1, operator mapping with `:semver`
11
+ * suffix detection (§5.3), multivariate weighted-values shape (§5.4),
12
+ * cross-env value-type coercion per D-F5.
13
+ *
14
+ * This module is a pure function — no I/O, no env reads, no module-level
15
+ * mutable state. Report accumulation flows through the `ConversionReport`
16
+ * argument supplied by the caller (the `flagsmith.ts` source).
17
+ */
18
+ import { inferValueType, toQuonfigValue } from '../../quonfig-target/values.js';
19
+ const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
20
+ /** Flagsmith identity-override rule keys context attributes under this propertyName. */
21
+ const IDENTITY_PROPERTY_NAME = 'user.key';
22
+ /** Suffix Flagsmith uses to switch a comparison operator from string to semver. */
23
+ const SEMVER_SUFFIX = ':semver';
24
+ /**
25
+ * Flagsmith feature names land directly under `feature-flags/` — Flagsmith
26
+ * enforces a lower-case-hyphenated naming policy on most projects, so the
27
+ * key is already filesystem-safe. We still defensively replace any `/` so
28
+ * a name can never spawn a nested directory.
29
+ */
30
+ export function flagOutputPath(name) {
31
+ return `feature-flags/${normalizeKey(name)}.json`;
32
+ }
33
+ export function segmentOutputPath(name) {
34
+ return `segments/${normalizeKey(name)}.json`;
35
+ }
36
+ function normalizeKey(key) {
37
+ return key.replaceAll('/', '.');
38
+ }
39
+ /** Env name → Quonfig env id slug (matches the source-module slugifier). */
40
+ function slugifyEnvName(name) {
41
+ return name
42
+ .toLowerCase()
43
+ .replaceAll(/[^\da-z]+/g, '-')
44
+ .replaceAll(/^-+|-+$/g, '');
45
+ }
46
+ /**
47
+ * Unwrap the `feature_state_value` field as it appears on the
48
+ * `/features/featurestates/` endpoint (envelope shape on env-default and
49
+ * segment-override rows; scalar on `/edge-identity-overrides` per API5).
50
+ * Returns the raw JS value Flagsmith would have served before any Quonfig
51
+ * type coercion.
52
+ */
53
+ export function coerceEnvelopeOrScalar(v) {
54
+ if (v === undefined)
55
+ return null;
56
+ if (v === null)
57
+ return null;
58
+ if (typeof v === 'object') {
59
+ if (v.type === 'bool')
60
+ return v.boolean_value ?? false;
61
+ if (v.type === 'int')
62
+ return v.integer_value ?? 0;
63
+ // 'unicode' (and any unknown envelope type) defaults to the string slot.
64
+ return v.string_value ?? '';
65
+ }
66
+ return v;
67
+ }
68
+ /**
69
+ * Resolve a multivariate option's typed value to the raw JS value Flagsmith
70
+ * would have served. The option's discriminator is `type: 'unicode' | 'int' | 'bool'`.
71
+ */
72
+ function mvOptionValue(opt) {
73
+ if (opt.type === 'bool')
74
+ return opt.boolean_value ?? false;
75
+ if (opt.type === 'int')
76
+ return opt.integer_value ?? 0;
77
+ return opt.string_value ?? '';
78
+ }
79
+ /**
80
+ * True when a STANDARD feature has no value payload in any env — every
81
+ * env-default's `feature_state_value` envelope is shaped `{type: 'unicode',
82
+ * boolean_value: null, integer_value: null, string_value: null}` (the default
83
+ * Flagsmith emits for bool-only flags). In that shape the per-env `enabled`
84
+ * bit IS the value; treating the flag as `string` and coercing the empty
85
+ * envelope to `''` would silently lose the bool. The detection is bundle-pure
86
+ * so both `decideValueType` and the value-emission paths can re-check it.
87
+ */
88
+ function isValuelessStandardBool(bundle) {
89
+ const { feature, featurestates_by_env } = bundle;
90
+ if (feature.type !== 'STANDARD')
91
+ return false;
92
+ const envs = Object.values(featurestates_by_env);
93
+ if (envs.length === 0)
94
+ return false;
95
+ return envs.every((env) => {
96
+ const fsv = env.default?.feature_state_value;
97
+ if (!fsv || typeof fsv !== 'object')
98
+ return false;
99
+ return fsv.boolean_value === null && fsv.integer_value === null && fsv.string_value === null;
100
+ });
101
+ }
102
+ /**
103
+ * Decide the feature's Quonfig valueType. Multivariate features take their
104
+ * type from the variation values (all variants share a type in valid Flagsmith
105
+ * data); STANDARD features look at the env-default `feature_state_value` shape
106
+ * across every env. D-F5: if envs diverge, coerce to `string` and emit a loud
107
+ * report entry per diverged feature.
108
+ */
109
+ function decideValueType(bundle, report) {
110
+ const { feature, featurestates_by_env } = bundle;
111
+ const mvOptions = feature.multivariate_options ?? [];
112
+ // Value-less STANDARD feature: enabled IS the bool value (qfg-ybt9).
113
+ if (isValuelessStandardBool(bundle))
114
+ return 'bool';
115
+ if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
116
+ const variantValues = mvOptions.map((o) => mvOptionValue(o));
117
+ const types = new Set(variantValues.map((v) => inferValueType(v)));
118
+ if (types.size === 1) {
119
+ const only = [...types][0];
120
+ if (only === 'string' || only === 'int' || only === 'bool' || only === 'double')
121
+ return only;
122
+ }
123
+ // Mixed-type multivariates are vanishingly rare in Flagsmith; fall back to
124
+ // string so every variant serializes uniformly.
125
+ return 'string';
126
+ }
127
+ // STANDARD: scan every env-default row's value type. Identity/segment
128
+ // overrides can ALSO have divergent types in pathological data — we still
129
+ // only coerce when env-defaults disagree, since that's the visible case in
130
+ // §5.5 / D-F5; overrides on a mismatched feature lose precision the same way.
131
+ const envTypes = new Map();
132
+ for (const [envApiKey, bundleForEnv] of Object.entries(featurestates_by_env)) {
133
+ if (bundleForEnv.default) {
134
+ const raw = coerceEnvelopeOrScalar(bundleForEnv.default.feature_state_value);
135
+ envTypes.set(envApiKey, inferValueType(raw));
136
+ }
137
+ }
138
+ const distinct = new Set(envTypes.values());
139
+ if (distinct.size <= 1) {
140
+ const only = [...distinct][0];
141
+ if (only === undefined)
142
+ return 'string';
143
+ if (only === 'string' || only === 'int' || only === 'bool' || only === 'double')
144
+ return only;
145
+ // 'json' (null/object/array) is rare for Flagsmith primitives — leave as string.
146
+ return 'string';
147
+ }
148
+ // D-F5: multiple envs disagree on type — coerce to string and report.
149
+ const detail = `feature_state_value type diverges across environments (${[...envTypes.entries()]
150
+ .map(([env, t]) => `${env}=${t}`)
151
+ .join(', ')}) — Quonfig requires one valueType per flag, coerced to string`;
152
+ report.add('cross-env-value-type-coerced', feature.name, detail);
153
+ return 'string';
154
+ }
155
+ /**
156
+ * Map one Flagsmith condition to a Quonfig criterion, or signal a whole-rule
157
+ * drop with a reason. Implements plan §5.3 verbatim.
158
+ */
159
+ export function mapCondition(condition) {
160
+ const { operator, property, value } = condition;
161
+ const propertyName = property && property.length > 0 ? property : 'user.key';
162
+ const hasSemver = typeof value === 'string' && value.endsWith(SEMVER_SUFFIX);
163
+ const semverValue = hasSemver ? value.slice(0, -SEMVER_SUFFIX.length) : null;
164
+ switch (operator) {
165
+ case 'EQUAL': {
166
+ if (hasSemver) {
167
+ return {
168
+ criterion: {
169
+ operator: 'PROP_SEMVER_EQUAL',
170
+ propertyName,
171
+ valueToMatch: { type: 'string', value: semverValue ?? '' },
172
+ },
173
+ };
174
+ }
175
+ return {
176
+ criterion: {
177
+ operator: 'PROP_IS_ONE_OF',
178
+ propertyName,
179
+ valueToMatch: { type: 'string_list', value: [String(value ?? '')] },
180
+ },
181
+ };
182
+ }
183
+ case 'NOT_EQUAL': {
184
+ if (hasSemver) {
185
+ return {
186
+ drop: true,
187
+ reason: 'NOT_EQUAL with :semver suffix has no Quonfig equivalent (no PROP_SEMVER_NOT_EQUAL)',
188
+ };
189
+ }
190
+ return {
191
+ criterion: {
192
+ operator: 'PROP_IS_NOT_ONE_OF',
193
+ propertyName,
194
+ valueToMatch: { type: 'string_list', value: [String(value ?? '')] },
195
+ },
196
+ };
197
+ }
198
+ case 'GREATER_THAN': {
199
+ if (hasSemver) {
200
+ return {
201
+ criterion: {
202
+ operator: 'PROP_SEMVER_GREATER_THAN',
203
+ propertyName,
204
+ valueToMatch: { type: 'string', value: semverValue ?? '' },
205
+ },
206
+ };
207
+ }
208
+ return {
209
+ criterion: makeNumericCriterion('PROP_GREATER_THAN', propertyName, value),
210
+ };
211
+ }
212
+ case 'GREATER_THAN_INCLUSIVE': {
213
+ if (hasSemver) {
214
+ return {
215
+ drop: true,
216
+ reason: 'GREATER_THAN_INCLUSIVE with :semver suffix has no Quonfig equivalent (no PROP_SEMVER_GREATER_THAN_OR_EQUAL)',
217
+ };
218
+ }
219
+ return {
220
+ criterion: makeNumericCriterion('PROP_GREATER_THAN_OR_EQUAL', propertyName, value),
221
+ };
222
+ }
223
+ case 'LESS_THAN': {
224
+ if (hasSemver) {
225
+ return {
226
+ criterion: {
227
+ operator: 'PROP_SEMVER_LESS_THAN',
228
+ propertyName,
229
+ valueToMatch: { type: 'string', value: semverValue ?? '' },
230
+ },
231
+ };
232
+ }
233
+ return {
234
+ criterion: makeNumericCriterion('PROP_LESS_THAN', propertyName, value),
235
+ };
236
+ }
237
+ case 'LESS_THAN_INCLUSIVE': {
238
+ if (hasSemver) {
239
+ return {
240
+ drop: true,
241
+ reason: 'LESS_THAN_INCLUSIVE with :semver suffix has no Quonfig equivalent (no PROP_SEMVER_LESS_THAN_OR_EQUAL)',
242
+ };
243
+ }
244
+ return {
245
+ criterion: makeNumericCriterion('PROP_LESS_THAN_OR_EQUAL', propertyName, value),
246
+ };
247
+ }
248
+ case 'CONTAINS': {
249
+ return {
250
+ criterion: {
251
+ operator: 'PROP_CONTAINS_ONE_OF',
252
+ propertyName,
253
+ valueToMatch: { type: 'string_list', value: [String(value ?? '')] },
254
+ },
255
+ };
256
+ }
257
+ case 'NOT_CONTAINS': {
258
+ return {
259
+ criterion: {
260
+ operator: 'PROP_DOES_NOT_CONTAIN_ONE_OF',
261
+ propertyName,
262
+ valueToMatch: { type: 'string_list', value: [String(value ?? '')] },
263
+ },
264
+ };
265
+ }
266
+ case 'REGEX': {
267
+ return {
268
+ criterion: {
269
+ operator: 'PROP_MATCHES',
270
+ propertyName,
271
+ valueToMatch: { type: 'string', value: String(value ?? '') },
272
+ },
273
+ };
274
+ }
275
+ case 'IS_SET': {
276
+ return { criterion: { operator: 'IS_PRESENT', propertyName } };
277
+ }
278
+ case 'IS_NOT_SET': {
279
+ return { criterion: { operator: 'IS_NOT_PRESENT', propertyName } };
280
+ }
281
+ case 'IN': {
282
+ // Flagsmith ships IN values as comma-separated strings, NOT JSON arrays.
283
+ const parts = typeof value === 'string'
284
+ ? value
285
+ .split(',')
286
+ .map((s) => s.trim())
287
+ .filter((s) => s.length > 0)
288
+ : [];
289
+ return {
290
+ criterion: {
291
+ operator: 'PROP_IS_ONE_OF',
292
+ propertyName,
293
+ valueToMatch: { type: 'string_list', value: parts.length > 0 ? parts : [String(value ?? '')] },
294
+ },
295
+ };
296
+ }
297
+ case 'MODULO': {
298
+ return { drop: true, reason: 'Flagsmith MODULO operator has no Quonfig equivalent' };
299
+ }
300
+ case 'PERCENTAGE_SPLIT': {
301
+ return {
302
+ drop: true,
303
+ reason: 'Flagsmith PERCENTAGE_SPLIT inside a segment condition has no Quonfig equivalent (rule-level weighted_values only)',
304
+ };
305
+ }
306
+ default: {
307
+ return { drop: true, reason: `unrecognized Flagsmith operator "${operator}"` };
308
+ }
309
+ }
310
+ }
311
+ function makeNumericCriterion(operator, propertyName, rawValue) {
312
+ const num = rawValue === null || rawValue === undefined || rawValue === '' ? 0 : Number(rawValue);
313
+ if (Number.isFinite(num)) {
314
+ return {
315
+ operator,
316
+ propertyName,
317
+ valueToMatch: { type: Number.isInteger(num) ? 'int' : 'double', value: num },
318
+ };
319
+ }
320
+ // Non-numeric value on a numeric operator — emit a string fallback. Flagsmith
321
+ // coerces at runtime so this is rare; keeping the operator preserves intent.
322
+ return { operator, propertyName, valueToMatch: { type: 'string', value: String(rawValue ?? '') } };
323
+ }
324
+ /**
325
+ * Convert a Flagsmith segment rule subtree (possibly nested with type=ALL/ANY/NONE)
326
+ * into a flat list of Quonfig criteria. Quonfig has no within-rule OR, so:
327
+ * - ALL (AND) is honored: each child contributes its criteria, ANDed.
328
+ * - ANY (OR) on a sibling rule list is a structural mismatch — we drop the
329
+ * rule with a precise reason rather than silently AND it.
330
+ * - NONE (NOT-ANY) — same: drop with reason.
331
+ *
332
+ * Returns null when the subtree cannot be flattened (some child dropped).
333
+ */
334
+ function flattenSegmentRuleForSegment(rule, segment, report) {
335
+ if (rule.type !== 'ALL') {
336
+ report.add('skipped-rule', segment.name, `Flagsmith segment rule with type=${rule.type} cannot be expressed in Quonfig (no within-rule OR); whole rule dropped`);
337
+ return null;
338
+ }
339
+ const criteria = [];
340
+ for (const cond of rule.conditions) {
341
+ const mapped = mapCondition(cond);
342
+ if ('drop' in mapped) {
343
+ report.add('skipped-rule', segment.name, `condition on "${cond.property ?? ''}" (${cond.operator}) dropped: ${mapped.reason}`);
344
+ return null;
345
+ }
346
+ criteria.push(mapped.criterion);
347
+ }
348
+ for (const child of rule.rules ?? []) {
349
+ const sub = flattenSegmentRuleForSegment(child, segment, report);
350
+ if (sub === null)
351
+ return null;
352
+ criteria.push(...sub);
353
+ }
354
+ return criteria;
355
+ }
356
+ /**
357
+ * Convert an env-default featurestate's served value into a Quonfig RuleValue.
358
+ *
359
+ * MULTIVARIATE features serve a weighted_values rollout (using per-env override
360
+ * weights when present, else project-level defaults). Identity-override rules
361
+ * on MV features handle pinning separately (see `identityOverrideRuleValue`).
362
+ */
363
+ function envDefaultRuleValue(bundle, envApiKey, fsState, ctx) {
364
+ const { feature } = bundle;
365
+ const mvOptions = feature.multivariate_options ?? [];
366
+ // Value-less STANDARD bool (qfg-ybt9): enabled IS the value; envelope is empty.
367
+ if (isValuelessStandardBool(bundle)) {
368
+ return { type: 'bool', value: fsState?.enabled ?? false };
369
+ }
370
+ // enabled=false (D-F1)
371
+ if (fsState && !fsState.enabled) {
372
+ if (ctx.valueType === 'bool') {
373
+ return { type: 'bool', value: false };
374
+ }
375
+ if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
376
+ const detail = `non-boolean MULTIVARIATE feature is disabled in env "${envApiKey}" — Flagsmith would have served your code's default; Quonfig now serves the stored weighted_values`;
377
+ ctx.report.add('enabled-false-non-boolean', feature.name, detail);
378
+ }
379
+ else {
380
+ const stored = coerceEnvelopeOrScalar(fsState.feature_state_value);
381
+ const detail = `non-boolean feature is disabled in env "${envApiKey}" — Flagsmith would have served your code's default; Quonfig now serves ${JSON.stringify(stored)}`;
382
+ ctx.report.add('enabled-false-non-boolean', feature.name, detail);
383
+ }
384
+ // Fall through to served-value emission below so we still produce a value.
385
+ }
386
+ if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
387
+ return multivariateWeightedValue(mvOptions, fsState?.multivariate_feature_state_values ?? [], ctx);
388
+ }
389
+ if (!fsState) {
390
+ // No default featurestate for this env (rare — partial-fetch error). Serve
391
+ // the value-type's zero-ish typed value so downstream validation accepts it.
392
+ return zeroValueFor(ctx.valueType);
393
+ }
394
+ const raw = coerceEnvelopeOrScalar(fsState.feature_state_value);
395
+ return toQuonfigValue(raw, ctx.valueType);
396
+ }
397
+ /** Identity-override rule value: pinned single variation OR re-weighted MV allocation (D-F7). */
398
+ function identityOverrideRuleValue(bundle, override, ctx) {
399
+ const { feature } = bundle;
400
+ const mvOptions = feature.multivariate_options ?? [];
401
+ const mvWeights = override.feature_state.multivariate_feature_state_values ?? [];
402
+ // Value-less STANDARD bool (qfg-ybt9): override's enabled IS the value.
403
+ if (isValuelessStandardBool(bundle)) {
404
+ return { type: 'bool', value: override.feature_state.enabled };
405
+ }
406
+ // enabled=false on the override: prefer the served value of the override's
407
+ // own feature_state_value, with the same D-F1 reporting if non-boolean.
408
+ if (!override.feature_state.enabled) {
409
+ if (ctx.valueType === 'bool') {
410
+ return { type: 'bool', value: false };
411
+ }
412
+ if (feature.type !== 'MULTIVARIATE') {
413
+ ctx.report.add('enabled-false-non-boolean', feature.name, `identity override "${override.identifier}" is disabled — Flagsmith would have served your code's default; Quonfig now serves the stored value`);
414
+ }
415
+ }
416
+ // MV with re-weighted allocation (D-F7): per-identity weight vector wins.
417
+ if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0 && mvWeights.length > 0) {
418
+ return multivariateWeightedValue(mvOptions, mvWeights, ctx);
419
+ }
420
+ // MV pinned to a single variation — also expressed via the SDK as a scalar
421
+ // `feature_state_value` matching one of the variant values. Emit as the
422
+ // typed scalar; Quonfig serves that variant.
423
+ const raw = coerceEnvelopeOrScalar(override.feature_state.feature_state_value);
424
+ return toQuonfigValue(raw, ctx.valueType);
425
+ }
426
+ /**
427
+ * Like `envDefaultRuleValue` but never emits report entries — used by the
428
+ * baseline `default.rules` so the D-F1 / D-F5 sentinels are only counted once
429
+ * (during the per-env walk).
430
+ */
431
+ function servedValueSilent(bundle, fsState, ctx) {
432
+ const { feature } = bundle;
433
+ const mvOptions = feature.multivariate_options ?? [];
434
+ // Value-less STANDARD bool (qfg-ybt9): enabled IS the value.
435
+ if (isValuelessStandardBool(bundle)) {
436
+ return { type: 'bool', value: fsState?.enabled ?? false };
437
+ }
438
+ if (fsState && !fsState.enabled && ctx.valueType === 'bool') {
439
+ return { type: 'bool', value: false };
440
+ }
441
+ if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
442
+ return multivariateWeightedValue(mvOptions, fsState?.multivariate_feature_state_values ?? [], ctx);
443
+ }
444
+ if (!fsState)
445
+ return zeroValueFor(ctx.valueType);
446
+ const raw = coerceEnvelopeOrScalar(fsState.feature_state_value);
447
+ return toQuonfigValue(raw, ctx.valueType);
448
+ }
449
+ /** Build a Quonfig `weighted_values` rule value from a list of MV options + (optional) per-env weights. */
450
+ function multivariateWeightedValue(mvOptions, perEnvWeights, ctx) {
451
+ // Index per-env weights by MV option id for fast lookup.
452
+ const weightByOption = new Map();
453
+ for (const w of perEnvWeights)
454
+ weightByOption.set(w.multivariate_feature_option, w.percentage_allocation);
455
+ // Flagsmith weights are percentages 0–100; Quonfig uses 0–100_000 (1000 units = 1%).
456
+ const weightedValues = mvOptions.map((opt) => {
457
+ const pct = weightByOption.get(opt.id) ?? opt.default_percentage_allocation;
458
+ return {
459
+ value: toQuonfigValue(mvOptionValue(opt), ctx.valueType),
460
+ weight: Math.round(pct * 1000),
461
+ };
462
+ });
463
+ return {
464
+ type: 'weighted_values',
465
+ value: {
466
+ hashByPropertyName: 'user.key',
467
+ weightedValues,
468
+ },
469
+ };
470
+ }
471
+ function zeroValueFor(valueType) {
472
+ switch (valueType) {
473
+ case 'bool': {
474
+ return { type: 'bool', value: false };
475
+ }
476
+ case 'double': {
477
+ return { type: 'double', value: 0 };
478
+ }
479
+ case 'int': {
480
+ return { type: 'int', value: 0 };
481
+ }
482
+ case 'json': {
483
+ return { type: 'json', value: null };
484
+ }
485
+ case 'string_list': {
486
+ return { type: 'string_list', value: [] };
487
+ }
488
+ default: {
489
+ return { type: 'string', value: '' };
490
+ }
491
+ }
492
+ }
493
+ /**
494
+ * Build the variants[] array for a Quonfig feature flag. MULTIVARIATE features
495
+ * surface each Flagsmith option as a variant; STANDARD bool features get the
496
+ * implicit `true`/`false` variants Quonfig already understands; STANDARD
497
+ * value-typed features have an empty variants[] (Quonfig allows that for
498
+ * non-rollout flags).
499
+ */
500
+ function buildVariants(feature, valueType) {
501
+ const mvOptions = feature.multivariate_options ?? [];
502
+ if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
503
+ return mvOptions.map((opt) => {
504
+ const raw = mvOptionValue(opt);
505
+ const variant = { value: toQuonfigValue(raw, valueType) };
506
+ // Flagsmith doesn't give MV options a name — use the stringified value
507
+ // for human-readability in the Quonfig UI.
508
+ variant.name = String(raw);
509
+ return variant;
510
+ });
511
+ }
512
+ if (valueType === 'bool') {
513
+ return [
514
+ { name: 'true', value: { type: 'bool', value: true } },
515
+ { name: 'false', value: { type: 'bool', value: false } },
516
+ ];
517
+ }
518
+ return [];
519
+ }
520
+ /**
521
+ * Translate one Flagsmith feature (with all its per-env state) into a Quonfig
522
+ * feature-flag config object.
523
+ */
524
+ export function translateFeature(bundle, report, options = {}) {
525
+ const { feature, featurestates_by_env, feature_segments_by_env } = bundle;
526
+ const valueType = decideValueType(bundle, report);
527
+ const tagLabelById = new Map();
528
+ for (const t of options.tags ?? [])
529
+ tagLabelById.set(t.id, t.label);
530
+ const segmentNameById = options.segmentNameById ?? new Map();
531
+ // Per-env lookup: FeatureSegment.id -> Segment.id. The featurestates endpoint
532
+ // returns `feature_segment` as the join-table id (a number), not the
533
+ // underlying segment id; resolve via `feature_segments_by_env` which the
534
+ // fetcher already populated per (env, feature).
535
+ const segmentIdByFeatureSegmentId = {};
536
+ for (const [envApiKey, fseg] of Object.entries(feature_segments_by_env ?? {})) {
537
+ const m = new Map();
538
+ for (const fs of fseg)
539
+ m.set(fs.id, fs.segment);
540
+ segmentIdByFeatureSegmentId[envApiKey] = m;
541
+ }
542
+ const ctx = { feature, report, segmentNameById, tagLabelById, valueType };
543
+ // Plan §5.4: every MULTIVARIATE feature triggers a re-bucketing note —
544
+ // Flagsmith and Quonfig hash identities with different salts, so individual
545
+ // identity → variation assignments change post-migration regardless of any
546
+ // per-env override structure. The overall percentage allocation is preserved.
547
+ if (feature.type === 'MULTIVARIATE' && (feature.multivariate_options?.length ?? 0) > 0) {
548
+ report.add('rebucketed-rollout', feature.name, 'multivariate feature; Flagsmith and Quonfig hash identities into buckets with different salts, so ' +
549
+ 'individual identity → variation assignments will change after migration (the overall percentage allocation is preserved)');
550
+ }
551
+ // Per-env rule list synthesis (plan §5.1, D-F2 ordering): identity overrides
552
+ // first, then segment overrides (already sorted ASC by priority by the
553
+ // fetcher), then the trailing ALWAYS_TRUE serving the env default.
554
+ const environments = Object.entries(featurestates_by_env)
555
+ .map(([envApiKey, envBundle]) => {
556
+ const rules = [];
557
+ // 1. Identity-override rules (D-F2 first).
558
+ for (const idov of envBundle.identity_overrides) {
559
+ const value = identityOverrideRuleValue(bundle, idov, ctx);
560
+ rules.push({
561
+ criteria: [
562
+ {
563
+ operator: 'PROP_IS_ONE_OF',
564
+ propertyName: IDENTITY_PROPERTY_NAME,
565
+ valueToMatch: { type: 'string_list', value: [idov.identifier] },
566
+ },
567
+ ],
568
+ value,
569
+ });
570
+ ctx.report.add('identity-override-as-rule', feature.name, `identity "${idov.identifier}" in env "${envApiKey}" inlined as a leading PROP_IS_ONE_OF rule on ${IDENTITY_PROPERTY_NAME}. ` +
571
+ `Quonfig v1 has no individual-target primitive — adding/removing identities now requires a config edit, not a separate UI.`);
572
+ }
573
+ // 2. Segment-override rules (D-F2 second; fetcher pre-sorted by priority ASC).
574
+ for (const segov of envBundle.segment_overrides) {
575
+ const fsegRef = segov.feature_segment;
576
+ const segmentId = typeof fsegRef === 'object' && fsegRef !== null
577
+ ? fsegRef.segment
578
+ : typeof fsegRef === 'number'
579
+ ? (segmentIdByFeatureSegmentId[envApiKey]?.get(fsegRef) ?? null)
580
+ : null;
581
+ const segmentName = segmentId === null ? undefined : segmentNameById.get(segmentId);
582
+ if (!segmentName) {
583
+ // Unresolvable segment reference — drop with report so it can't go silent.
584
+ ctx.report.add('skipped-rule', feature.name, `segment-override row in env "${envApiKey}" references segment id ${segmentId ?? '(unknown)'} which is not in the project segment pool — rule dropped`);
585
+ continue;
586
+ }
587
+ // Per D-F4: a segment whose rules include PERCENTAGE_SPLIT or MODULO
588
+ // would already be a structural mismatch. The drop happens inside the
589
+ // segment-file translation; here we still emit the IN_SEG override
590
+ // because the segment file exists. The segment-file rules may have
591
+ // been emptied (just the trailing catch-all left) — that's the user's
592
+ // signal that the override never matches anyone.
593
+ rules.push(buildSegmentOverrideRule(segov, segmentName, bundle, ctx));
594
+ }
595
+ // 3. Trailing ALWAYS_TRUE rule serving the env default.
596
+ rules.push({
597
+ criteria: [ALWAYS_TRUE],
598
+ value: envDefaultRuleValue(bundle, envApiKey, envBundle.default, ctx),
599
+ });
600
+ const readableName = options.envNameByApiKey?.get(envApiKey) ?? envApiKey;
601
+ return { id: slugifyEnvName(readableName), rules };
602
+ })
603
+ // Stable output order.
604
+ .sort((a, b) => a.id.localeCompare(b.id));
605
+ // Baseline `default` rules: serve the first env's default value (or the
606
+ // value-type's zero) so any env Flagsmith didn't define has a defined
607
+ // fallback. Mirrors LD's choice in `translateFlag`. Use the silent helper
608
+ // so we don't double-emit the D-F1 / D-F5 sentinel notes — those were
609
+ // already accumulated by the per-env walk above.
610
+ const firstEnvApiKey = Object.keys(featurestates_by_env)[0];
611
+ const firstEnvBundle = firstEnvApiKey ? featurestates_by_env[firstEnvApiKey] : undefined;
612
+ const defaultRules = [
613
+ {
614
+ criteria: [ALWAYS_TRUE],
615
+ value: firstEnvBundle ? servedValueSilent(bundle, firstEnvBundle.default, ctx) : zeroValueFor(valueType),
616
+ },
617
+ ];
618
+ const out = {
619
+ key: normalizeKey(feature.name),
620
+ type: 'feature_flag',
621
+ valueType,
622
+ default: { rules: defaultRules },
623
+ environments,
624
+ variants: buildVariants(feature, valueType),
625
+ };
626
+ if (feature.description)
627
+ out.description = feature.description;
628
+ const tagLabels = (feature.tags ?? [])
629
+ .map((id) => tagLabelById.get(id))
630
+ .filter((label) => typeof label === 'string');
631
+ if (tagLabels.length > 0)
632
+ out.tags = tagLabels;
633
+ return out;
634
+ }
635
+ /** Build the rule that triggers when a context is in `segmentName`. */
636
+ function buildSegmentOverrideRule(segov, segmentName, bundle, ctx) {
637
+ const { feature } = bundle;
638
+ const mvOptions = feature.multivariate_options ?? [];
639
+ let value;
640
+ if (!segov.enabled) {
641
+ if (ctx.valueType === 'bool') {
642
+ value = { type: 'bool', value: false };
643
+ }
644
+ else if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
645
+ ctx.report.add('enabled-false-non-boolean', feature.name, `segment override "${segmentName}" is disabled — Flagsmith would have served your code's default; Quonfig now serves the stored weighted_values`);
646
+ value = multivariateWeightedValue(mvOptions, segov.multivariate_feature_state_values ?? [], ctx);
647
+ }
648
+ else {
649
+ const stored = coerceEnvelopeOrScalar(segov.feature_state_value);
650
+ ctx.report.add('enabled-false-non-boolean', feature.name, `segment override "${segmentName}" is disabled — Flagsmith would have served your code's default; Quonfig now serves ${JSON.stringify(stored)}`);
651
+ value = toQuonfigValue(stored, ctx.valueType);
652
+ }
653
+ }
654
+ else if (feature.type === 'MULTIVARIATE' && mvOptions.length > 0) {
655
+ value = multivariateWeightedValue(mvOptions, segov.multivariate_feature_state_values ?? [], ctx);
656
+ }
657
+ else {
658
+ const raw = coerceEnvelopeOrScalar(segov.feature_state_value);
659
+ value = toQuonfigValue(raw, ctx.valueType);
660
+ }
661
+ return {
662
+ criteria: [
663
+ {
664
+ operator: 'IN_SEG',
665
+ valueToMatch: { type: 'string', value: segmentName },
666
+ },
667
+ ],
668
+ value,
669
+ };
670
+ }
671
+ /**
672
+ * Translate one Flagsmith segment into a Quonfig segment config object.
673
+ *
674
+ * Quonfig segments are boolean ("is the context in the segment?") and have no
675
+ * per-environment state, so all rules collapse into `default.rules`,
676
+ * first-match-wins. A rule whose conditions all map yields a true-valued rule;
677
+ * a trailing ALWAYS_TRUE serves false (not in segment).
678
+ */
679
+ export function translateSegment(segment, report) {
680
+ const rules = [];
681
+ for (const rule of segment.rules ?? []) {
682
+ const criteria = flattenSegmentRuleForSegment(rule, segment, report);
683
+ if (criteria === null)
684
+ continue;
685
+ rules.push({
686
+ criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE],
687
+ value: { type: 'bool', value: true },
688
+ });
689
+ }
690
+ // Trailing catch-all: anyone not matched is not in the segment.
691
+ rules.push({ criteria: [ALWAYS_TRUE], value: { type: 'bool', value: false } });
692
+ const out = {
693
+ key: normalizeKey(segment.name),
694
+ type: 'segment',
695
+ valueType: 'bool',
696
+ default: { rules },
697
+ environments: [],
698
+ variants: [],
699
+ };
700
+ if (segment.description)
701
+ out.description = segment.description;
702
+ return out;
703
+ }