@quonfig/cli 0.0.50 → 0.0.51

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