@quonfig/cli 0.0.46 → 0.0.48

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.
Files changed (32) hide show
  1. package/README.md +71 -66
  2. package/dist/commands/migrate.d.ts +2 -0
  3. package/dist/commands/migrate.js +68 -8
  4. package/dist/migrate/local-write.js +4 -0
  5. package/dist/migrate/migration-report.d.ts +9 -0
  6. package/dist/migrate/migration-report.js +68 -0
  7. package/dist/migrate/push-to-cloud.js +2 -0
  8. package/dist/migrate/quonfig-target/operators.d.ts +27 -0
  9. package/dist/migrate/quonfig-target/operators.js +71 -0
  10. package/dist/migrate/quonfig-target/report.d.ts +59 -0
  11. package/dist/migrate/quonfig-target/report.js +39 -0
  12. package/dist/migrate/quonfig-target/ruleset.d.ts +127 -0
  13. package/dist/migrate/quonfig-target/ruleset.js +192 -0
  14. package/dist/migrate/quonfig-target/values.d.ts +38 -0
  15. package/dist/migrate/quonfig-target/values.js +77 -0
  16. package/dist/migrate/source-api-key.d.ts +33 -0
  17. package/dist/migrate/source-api-key.js +43 -0
  18. package/dist/migrate/source.d.ts +10 -0
  19. package/dist/migrate/sources/launchdarkly/api.d.ts +59 -0
  20. package/dist/migrate/sources/launchdarkly/api.js +195 -0
  21. package/dist/migrate/sources/launchdarkly/audit.d.ts +161 -0
  22. package/dist/migrate/sources/launchdarkly/audit.js +172 -0
  23. package/dist/migrate/sources/launchdarkly/translate.d.ts +39 -0
  24. package/dist/migrate/sources/launchdarkly/translate.js +209 -0
  25. package/dist/migrate/sources/launchdarkly/types.d.ts +187 -0
  26. package/dist/migrate/sources/launchdarkly/types.js +10 -0
  27. package/dist/migrate/sources/launchdarkly.d.ts +35 -0
  28. package/dist/migrate/sources/launchdarkly.js +295 -13
  29. package/dist/version.d.ts +1 -1
  30. package/dist/version.js +1 -1
  31. package/oclif.manifest.json +178 -161
  32. package/package.json +5 -5
@@ -1,4 +1,5 @@
1
1
  import type { IdentifierMap } from './identifier-map.js';
2
+ import type { ConversionNote } from './quonfig-target/report.js';
2
3
  import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolutionSummary, SkippedConfigSummary } from './source.js';
3
4
  export interface MigrationReportCounts {
4
5
  configsMigrated: number;
@@ -38,6 +39,14 @@ export interface MigrationReportData {
38
39
  * was coerced.
39
40
  */
40
41
  coercedSentinels?: CoercedSentinelSummary | null;
42
+ /**
43
+ * Structured conversion notes from a provider's `translate()` — re-bucketed
44
+ * rollouts, dropped prerequisites, lossy individual-target conversions, etc.
45
+ * (the LaunchDarkly `quonfig-target/report.ts` set). Rendered into the
46
+ * "Users will be re-bucketed" + "Conversion notes" sections. Undefined or
47
+ * empty when the source emitted none.
48
+ */
49
+ conversionNotes?: ConversionNote[];
41
50
  counts: MigrationReportCounts;
42
51
  /**
43
52
  * Override sections that were dropped during translate() because the env.id was not
@@ -126,6 +126,68 @@ const renderSkippedConfigs = (skipped) => {
126
126
  }
127
127
  return lines.join('\n');
128
128
  };
129
+ /**
130
+ * Human-readable headings for the non-rollout conversion-note categories.
131
+ * `skipped-config` is intentionally absent — it has its own dedicated section
132
+ * (`renderSkippedConfigs`). `rebucketed-rollout` is absent because it gets its
133
+ * own top-level section (`renderRebucketedRollouts`) — plan §5.4 requires it
134
+ * be impossible to miss.
135
+ */
136
+ const CONVERSION_NOTE_HEADINGS = {
137
+ 'ai-config-skipped': 'AI Configs skipped (out of v1 scope)',
138
+ 'dropped-custom-properties': 'Dropped custom properties',
139
+ 'dropped-experiment-metadata': 'Dropped experiment metadata',
140
+ 'dropped-maintainer': 'Dropped maintainer metadata',
141
+ 'dropped-mobile-key-availability': 'Dropped mobile-key availability',
142
+ 'dropped-prerequisite': 'Dropped prerequisites',
143
+ 'dropped-private-attribute': 'Dropped private attributes',
144
+ 'individual-target-as-rule': 'Individual targets converted to rules',
145
+ 'skipped-rule': 'Skipped rules (unsupported operators)',
146
+ 'unexportable-segment-membership': 'Unexportable segment membership',
147
+ };
148
+ const renderRebucketedRollouts = (notes) => {
149
+ const rollouts = (notes ?? []).filter((n) => n.category === 'rebucketed-rollout');
150
+ if (rollouts.length === 0)
151
+ return null;
152
+ const lines = [
153
+ '## Users will be re-bucketed',
154
+ '',
155
+ `**${rollouts.length}** flag(s)/segment(s) use a percentage rollout. LaunchDarkly and Quonfig hash buckets ` +
156
+ `differently, so which users land in which bucket *will change* after migration — the rollout percentage is ` +
157
+ `preserved, but individual user assignments are not. Review these before cutover so the re-bucketing is not a surprise:`,
158
+ '',
159
+ ];
160
+ const sorted = [...rollouts].sort((a, b) => a.key.localeCompare(b.key));
161
+ for (const note of sorted) {
162
+ lines.push(`- \`${note.key}\` — ${note.detail}`);
163
+ }
164
+ return lines.join('\n');
165
+ };
166
+ const renderConversionNotes = (notes) => {
167
+ // `skipped-config` (own section) and `rebucketed-rollout` (own section) are
168
+ // rendered elsewhere — everything else is grouped by category here.
169
+ const grouped = (notes ?? []).filter((n) => n.category !== 'rebucketed-rollout' && n.category !== 'skipped-config');
170
+ if (grouped.length === 0)
171
+ return null;
172
+ const lines = [
173
+ '## Conversion notes',
174
+ '',
175
+ `**${grouped.length}** note(s) where the converter could not represent a source concept exactly. Nothing was ` +
176
+ `silently dropped — each entry below names the exact flag/segment and what was lost so you can rebuild it by hand if needed.`,
177
+ ];
178
+ const categoriesInOrder = Object.keys(CONVERSION_NOTE_HEADINGS);
179
+ for (const category of categoriesInOrder) {
180
+ const forCategory = grouped.filter((n) => n.category === category);
181
+ if (forCategory.length === 0)
182
+ continue;
183
+ lines.push('', `### ${CONVERSION_NOTE_HEADINGS[category]}`, '');
184
+ const sorted = [...forCategory].sort((a, b) => a.key.localeCompare(b.key));
185
+ for (const note of sorted) {
186
+ lines.push(`- \`${note.key}\` — ${note.detail}`);
187
+ }
188
+ }
189
+ return lines.join('\n');
190
+ };
129
191
  const renderFollowUp = (followUp) => {
130
192
  const must = followUp.mustFixBeforeCutover.length === 0
131
193
  ? NONE
@@ -163,6 +225,12 @@ export const buildMigrationReport = (data) => {
163
225
  const coerced = renderCoercedSentinels(data.coercedSentinels);
164
226
  if (coerced !== null)
165
227
  sections.push(coerced);
228
+ const rebucketed = renderRebucketedRollouts(data.conversionNotes);
229
+ if (rebucketed !== null)
230
+ sections.push(rebucketed);
231
+ const conversionNotes = renderConversionNotes(data.conversionNotes);
232
+ if (conversionNotes !== null)
233
+ sections.push(conversionNotes);
166
234
  sections.push(renderFollowUp(data.followUp));
167
235
  return sections.join('\n\n') + '\n';
168
236
  };
@@ -94,6 +94,7 @@ export const pushMigrationToCloud = async (opts) => {
94
94
  acc.coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
95
95
  acc.duplicateResolutions =
96
96
  resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
97
+ const conversionNotes = opts.source.getConversionNotes?.() ?? null;
97
98
  const skippedTotal = (acc.skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
98
99
  const environmentsCount = opts.environments?.length ?? 0;
99
100
  const counts = buildMigrationCounts(livePaths, environmentsCount, skippedTotal);
@@ -102,6 +103,7 @@ export const pushMigrationToCloud = async (opts) => {
102
103
  ...opts.reportData,
103
104
  counts,
104
105
  ...(acc.coercedSentinels ? { coercedSentinels: acc.coercedSentinels } : {}),
106
+ ...(conversionNotes && conversionNotes.length > 0 ? { conversionNotes } : {}),
105
107
  ...(acc.droppedOverrides ? { droppedOverrides: acc.droppedOverrides } : {}),
106
108
  ...(acc.duplicateResolutions ? { duplicateResolutions: acc.duplicateResolutions } : {}),
107
109
  ...(acc.skippedConfigs ? { skippedConfigs: acc.skippedConfigs } : {}),
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared verb: provider clause operator + `negate` flag → Quonfig criterion
3
+ * operator, or a skip reason.
4
+ *
5
+ * This is part of the `quonfig-target/` verb library (plan §3.1, D1) — written
6
+ * once, consumed by every provider's `translate.ts`. The mapping table and the
7
+ * negation rules below are LaunchDarkly-shaped today, but the *shape* of the
8
+ * verb (op + negate → operator | skip) is provider-independent; a second
9
+ * provider extends the table rather than reinventing the dispatch.
10
+ */
11
+ /** Quonfig criterion operators — mirrors `OperatorSchema` in app-quonfig's config-schemas.ts. */
12
+ export type QuonfigOperator = 'ALWAYS_TRUE' | 'IN_SEG' | 'IS_NOT_PRESENT' | 'IS_PRESENT' | 'NOT_IN_SEG' | 'PROP_AFTER' | 'PROP_BEFORE' | 'PROP_CONTAINS_ONE_OF' | 'PROP_DOES_NOT_CONTAIN_ONE_OF' | 'PROP_DOES_NOT_END_WITH_ONE_OF' | 'PROP_DOES_NOT_MATCH' | 'PROP_DOES_NOT_START_WITH_ONE_OF' | 'PROP_ENDS_WITH_ONE_OF' | 'PROP_GREATER_THAN' | 'PROP_GREATER_THAN_OR_EQUAL' | 'PROP_IS_NOT_ONE_OF' | 'PROP_IS_ONE_OF' | 'PROP_LESS_THAN' | 'PROP_LESS_THAN_OR_EQUAL' | 'PROP_MATCHES' | 'PROP_SEMVER_EQUAL' | 'PROP_SEMVER_GREATER_THAN' | 'PROP_SEMVER_LESS_THAN' | 'PROP_STARTS_WITH_ONE_OF';
13
+ export type OperatorMapping = {
14
+ operator: QuonfigOperator;
15
+ } | {
16
+ reason: string;
17
+ skip: true;
18
+ };
19
+ /**
20
+ * Map a LaunchDarkly clause `op` + `negate` to a Quonfig operator.
21
+ *
22
+ * Returns `{skip: true, reason}` for clauses v1 cannot honor:
23
+ * - negated comparison/date/semver ops (D3)
24
+ * - `applicationVersionSupported` (no Quonfig equivalent)
25
+ * - any operator not in the LaunchDarkly enum we recognize
26
+ */
27
+ export declare function mapLaunchDarklyOperator(op: string, negate: boolean): OperatorMapping;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Shared verb: provider clause operator + `negate` flag → Quonfig criterion
3
+ * operator, or a skip reason.
4
+ *
5
+ * This is part of the `quonfig-target/` verb library (plan §3.1, D1) — written
6
+ * once, consumed by every provider's `translate.ts`. The mapping table and the
7
+ * negation rules below are LaunchDarkly-shaped today, but the *shape* of the
8
+ * verb (op + negate → operator | skip) is provider-independent; a second
9
+ * provider extends the table rather than reinventing the dispatch.
10
+ */
11
+ /**
12
+ * Operators that have a direct negated form in Quonfig. `negate: true` simply
13
+ * selects the negated operator — semantics-preserving, always converted.
14
+ */
15
+ const NEGATABLE = {
16
+ contains: { negated: 'PROP_DOES_NOT_CONTAIN_ONE_OF', plain: 'PROP_CONTAINS_ONE_OF' },
17
+ endsWith: { negated: 'PROP_DOES_NOT_END_WITH_ONE_OF', plain: 'PROP_ENDS_WITH_ONE_OF' },
18
+ in: { negated: 'PROP_IS_NOT_ONE_OF', plain: 'PROP_IS_ONE_OF' },
19
+ matches: { negated: 'PROP_DOES_NOT_MATCH', plain: 'PROP_MATCHES' },
20
+ segmentMatch: { negated: 'NOT_IN_SEG', plain: 'IN_SEG' },
21
+ startsWith: { negated: 'PROP_DOES_NOT_START_WITH_ONE_OF', plain: 'PROP_STARTS_WITH_ONE_OF' },
22
+ };
23
+ /**
24
+ * Comparison / date / semver operators. Quonfig has no negated form of any of
25
+ * these (decision D3). `negate: false` converts directly; `negate: true` is
26
+ * skip + report — an algebraic flip would need a within-rule OR for the
27
+ * missing-attribute case, which Quonfig cannot express.
28
+ */
29
+ const COMPARISON_ONLY = {
30
+ after: 'PROP_AFTER',
31
+ before: 'PROP_BEFORE',
32
+ greaterThan: 'PROP_GREATER_THAN',
33
+ greaterThanOrEqual: 'PROP_GREATER_THAN_OR_EQUAL',
34
+ lessThan: 'PROP_LESS_THAN',
35
+ lessThanOrEqual: 'PROP_LESS_THAN_OR_EQUAL',
36
+ semVerEqual: 'PROP_SEMVER_EQUAL',
37
+ semVerGreaterThan: 'PROP_SEMVER_GREATER_THAN',
38
+ semVerLessThan: 'PROP_SEMVER_LESS_THAN',
39
+ };
40
+ /**
41
+ * Map a LaunchDarkly clause `op` + `negate` to a Quonfig operator.
42
+ *
43
+ * Returns `{skip: true, reason}` for clauses v1 cannot honor:
44
+ * - negated comparison/date/semver ops (D3)
45
+ * - `applicationVersionSupported` (no Quonfig equivalent)
46
+ * - any operator not in the LaunchDarkly enum we recognize
47
+ */
48
+ export function mapLaunchDarklyOperator(op, negate) {
49
+ const negatable = NEGATABLE[op];
50
+ if (negatable) {
51
+ return { operator: negate ? negatable.negated : negatable.plain };
52
+ }
53
+ const comparison = COMPARISON_ONLY[op];
54
+ if (comparison) {
55
+ if (negate) {
56
+ return {
57
+ reason: `negated comparison operator "${op}" — Quonfig has no negated form and no within-rule OR, ` +
58
+ `so an algebraic flip is unsafe (missing-attribute trap). Recreate this clause by hand.`,
59
+ skip: true,
60
+ };
61
+ }
62
+ return { operator: comparison };
63
+ }
64
+ if (op === 'applicationVersionSupported') {
65
+ return {
66
+ reason: `operator "applicationVersionSupported" has no Quonfig equivalent`,
67
+ skip: true,
68
+ };
69
+ }
70
+ return { reason: `unrecognized LaunchDarkly operator "${op}"`, skip: true };
71
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Shared verb: structured conversion-report accumulator.
3
+ *
4
+ * Part of the `quonfig-target/` verb library (plan §3.1, D1). Every
5
+ * skip/drop/coerce path in a provider's `translate.ts` records a structured
6
+ * entry here so it lands in `MIGRATION_REPORT.md` — nothing is silently
7
+ * dropped (plan §5.4).
8
+ *
9
+ * The categories below are the LaunchDarkly v1 set; a second provider adds its
10
+ * own categories. The accumulator itself is provider-independent.
11
+ */
12
+ export type ConversionNoteCategory =
13
+ /** A flag/segment that could not be converted at all. */
14
+ 'skipped-config'
15
+ /** A rule dropped because a clause's operator has no v1 Quonfig mapping (D3). */
16
+ | 'skipped-rule'
17
+ /** An individual/context target converted to a leading PROP_IS_ONE_OF rule (lossy — plan §5.4). */
18
+ | 'individual-target-as-rule'
19
+ /** Prerequisites dropped — no cross-flag dependency operator in Quonfig v1. */
20
+ | 'dropped-prerequisite'
21
+ /** `privateAttributes` dropped — no Quonfig equivalent. */
22
+ | 'dropped-private-attribute'
23
+ /** Experiment rollout converted to plain weighted_values; seed/kind dropped. */
24
+ | 'dropped-experiment-metadata'
25
+ /** Maintainer metadata dropped — Quonfig authorship lives in git history. */
26
+ | 'dropped-maintainer'
27
+ /** `customProperties` dropped — report-only in v1 (D6). */
28
+ | 'dropped-custom-properties'
29
+ /** clientSideAvailability mobile-key dimension dropped. */
30
+ | 'dropped-mobile-key-availability'
31
+ /** A percentage rollout that will re-bucket users post-migration (plan §5.4). */
32
+ | 'rebucketed-rollout'
33
+ /** A big/synced segment whose membership is not exportable via REST. */
34
+ | 'unexportable-segment-membership'
35
+ /** An AI Config enumerated but not emitted (out of v1 scope, D9). */
36
+ | 'ai-config-skipped';
37
+ export interface ConversionNote {
38
+ category: ConversionNoteCategory;
39
+ /** Human-readable detail — names the exact thing lost and why. */
40
+ detail: string;
41
+ /** Source key the note is about (flag or segment key). */
42
+ key: string;
43
+ }
44
+ /**
45
+ * Collects conversion notes during a migration run. A provider source holds
46
+ * one instance, passes it to every `translate()` call, and surfaces its
47
+ * contents through the `MigrationSource` reporting accessors.
48
+ */
49
+ export declare class ConversionReport {
50
+ private readonly notes;
51
+ get size(): number;
52
+ add(category: ConversionNoteCategory, key: string, detail: string): void;
53
+ /** All notes, in insertion order. */
54
+ all(): ConversionNote[];
55
+ /** Notes of one category, in insertion order. */
56
+ byCategory(category: ConversionNoteCategory): ConversionNote[];
57
+ isEmpty(): boolean;
58
+ reset(): void;
59
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared verb: structured conversion-report accumulator.
3
+ *
4
+ * Part of the `quonfig-target/` verb library (plan §3.1, D1). Every
5
+ * skip/drop/coerce path in a provider's `translate.ts` records a structured
6
+ * entry here so it lands in `MIGRATION_REPORT.md` — nothing is silently
7
+ * dropped (plan §5.4).
8
+ *
9
+ * The categories below are the LaunchDarkly v1 set; a second provider adds its
10
+ * own categories. The accumulator itself is provider-independent.
11
+ */
12
+ /**
13
+ * Collects conversion notes during a migration run. A provider source holds
14
+ * one instance, passes it to every `translate()` call, and surfaces its
15
+ * contents through the `MigrationSource` reporting accessors.
16
+ */
17
+ export class ConversionReport {
18
+ notes = [];
19
+ get size() {
20
+ return this.notes.length;
21
+ }
22
+ add(category, key, detail) {
23
+ this.notes.push({ category, detail, key });
24
+ }
25
+ /** All notes, in insertion order. */
26
+ all() {
27
+ return [...this.notes];
28
+ }
29
+ /** Notes of one category, in insertion order. */
30
+ byCategory(category) {
31
+ return this.notes.filter((n) => n.category === category);
32
+ }
33
+ isEmpty() {
34
+ return this.notes.length === 0;
35
+ }
36
+ reset() {
37
+ this.notes.length = 0;
38
+ }
39
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Shared verb: collapse a provider's targeting model into Quonfig's ordered,
3
+ * first-match-wins rule list.
4
+ *
5
+ * Part of the `quonfig-target/` verb library (plan §3.1, D1). The hard part of
6
+ * every converter is topology translation into Quonfig's specific shape — no
7
+ * "flag off" toggle, no individual-target primitive, no within-rule OR. These
8
+ * verbs do the LaunchDarkly collapse; a second provider with a different
9
+ * topology composes them differently.
10
+ */
11
+ import type { ConversionReport } from './report.js';
12
+ import { type QuonfigOperator } from './operators.js';
13
+ import { type QuonfigValue, type QuonfigValueType } from './values.js';
14
+ export interface QuonfigCriterion {
15
+ operator: QuonfigOperator | 'ALWAYS_TRUE';
16
+ propertyName?: string;
17
+ valueToMatch?: QuonfigValue;
18
+ }
19
+ export interface QuonfigWeightedValues {
20
+ type: 'weighted_values';
21
+ value: {
22
+ hashByPropertyName: string;
23
+ weightedValues: Array<{
24
+ value: QuonfigValue;
25
+ weight: number;
26
+ }>;
27
+ };
28
+ }
29
+ export type QuonfigRuleValue = QuonfigValue | QuonfigWeightedValues;
30
+ export interface QuonfigRule {
31
+ criteria: QuonfigCriterion[];
32
+ value: QuonfigRuleValue;
33
+ }
34
+ interface SourceClause {
35
+ attribute: string;
36
+ contextKind?: string;
37
+ negate?: boolean;
38
+ op: string;
39
+ values: unknown[];
40
+ }
41
+ interface SourceRollout {
42
+ bucketBy?: string;
43
+ contextKind?: string;
44
+ kind?: string;
45
+ seed?: number;
46
+ variations: Array<{
47
+ variation: number;
48
+ weight: number;
49
+ }>;
50
+ }
51
+ interface SourceRule {
52
+ _id?: string;
53
+ clauses: SourceClause[];
54
+ rollout?: SourceRollout;
55
+ variation?: number;
56
+ }
57
+ interface SourceTarget {
58
+ contextKind?: string;
59
+ values: string[];
60
+ variation: number;
61
+ }
62
+ interface SourceVariationOrRollout {
63
+ rollout?: SourceRollout;
64
+ variation?: number;
65
+ }
66
+ interface SourceFlagEnvironment {
67
+ contextTargets?: SourceTarget[];
68
+ fallthrough?: SourceVariationOrRollout;
69
+ offVariation?: number;
70
+ on: boolean;
71
+ prerequisites?: Array<{
72
+ key: string;
73
+ variation: number;
74
+ }>;
75
+ rules?: SourceRule[];
76
+ targets?: SourceTarget[];
77
+ }
78
+ export interface RulesetContext {
79
+ report: ConversionReport;
80
+ /** Source flag/segment key — used for precise report entries. */
81
+ sourceKey: string;
82
+ /** The flag's resolved Quonfig value type. */
83
+ valueType: QuonfigValueType;
84
+ /** Raw variation values, indexed as the provider indexes them. */
85
+ variationValues: unknown[];
86
+ }
87
+ /**
88
+ * LaunchDarkly attribute → Quonfig property name. The context kind becomes a
89
+ * namespace prefix (plan §5.1: `organization` + `plan` → `organization.plan`),
90
+ * defaulting to `user`. A nested JSON-pointer attribute (`/address/city`)
91
+ * becomes a dotted path (`address.city`).
92
+ */
93
+ export declare function normalizeAttribute(contextKind: string | undefined, attribute: string): string;
94
+ /**
95
+ * Map one provider clause to a Quonfig criterion, or `null` when the clause's
96
+ * operator has no v1 mapping (D3 — negated comparisons, applicationVersion…).
97
+ * A null result means the whole containing rule must be dropped: keeping a
98
+ * partial AND would silently broaden targeting.
99
+ */
100
+ export declare function clauseToCriterion(clause: SourceClause, ctx: RulesetContext): QuonfigCriterion | null;
101
+ /**
102
+ * Build a Quonfig rule from a provider rule's clauses (AND semantics) plus its
103
+ * served value. Returns `null` when any clause cannot be mapped — the rule is
104
+ * dropped wholesale and reported, rather than silently broadened.
105
+ */
106
+ export declare function buildRule(clauses: SourceClause[], value: QuonfigRuleValue, ctx: RulesetContext): QuonfigRule | null;
107
+ /**
108
+ * Convert a provider rollout to a Quonfig `weighted_values`. The experiment
109
+ * framing (`kind: "experiment"` + `seed`) is dropped + reported (plan §5.4),
110
+ * and every rollout is recorded as re-bucketing (LD and Quonfig hash
111
+ * differently, so post-migration bucket assignments differ).
112
+ */
113
+ export declare function rolloutToWeightedValues(rollout: SourceRollout, ctx: RulesetContext): QuonfigWeightedValues;
114
+ /** Resolve a `variation`-or-`rollout` pointer (rule served value / fallthrough). */
115
+ export declare function servedValue(voR: SourceVariationOrRollout, ctx: RulesetContext): QuonfigRuleValue;
116
+ /**
117
+ * Collapse a single LaunchDarkly per-environment flag state into an ordered,
118
+ * first-match-wins Quonfig rule list.
119
+ *
120
+ * - `on: false` → one ALWAYS_TRUE rule serving the `offVariation` value.
121
+ * - `on: true` → individual/context targets become leading PROP_IS_ONE_OF
122
+ * rules; `rules[]` translate in order; `fallthrough` becomes a trailing
123
+ * ALWAYS_TRUE rule. Prerequisites are dropped + reported (no Quonfig
124
+ * cross-flag dependency operator in v1).
125
+ */
126
+ export declare function collapseEnvironment(env: SourceFlagEnvironment, ctx: RulesetContext): QuonfigRule[];
127
+ export {};
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Shared verb: collapse a provider's targeting model into Quonfig's ordered,
3
+ * first-match-wins rule list.
4
+ *
5
+ * Part of the `quonfig-target/` verb library (plan §3.1, D1). The hard part of
6
+ * every converter is topology translation into Quonfig's specific shape — no
7
+ * "flag off" toggle, no individual-target primitive, no within-rule OR. These
8
+ * verbs do the LaunchDarkly collapse; a second provider with a different
9
+ * topology composes them differently.
10
+ */
11
+ import { mapLaunchDarklyOperator } from './operators.js';
12
+ import { toQuonfigValue } from './values.js';
13
+ const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
14
+ /** Operators whose `valueToMatch` is a `string_list` of every clause value. */
15
+ const STRING_LIST_OPERATORS = new Set([
16
+ 'PROP_CONTAINS_ONE_OF',
17
+ 'PROP_DOES_NOT_CONTAIN_ONE_OF',
18
+ 'PROP_DOES_NOT_END_WITH_ONE_OF',
19
+ 'PROP_DOES_NOT_START_WITH_ONE_OF',
20
+ 'PROP_ENDS_WITH_ONE_OF',
21
+ 'PROP_IS_NOT_ONE_OF',
22
+ 'PROP_IS_ONE_OF',
23
+ 'PROP_STARTS_WITH_ONE_OF',
24
+ ]);
25
+ const NUMERIC_OPERATORS = new Set([
26
+ 'PROP_GREATER_THAN',
27
+ 'PROP_GREATER_THAN_OR_EQUAL',
28
+ 'PROP_LESS_THAN',
29
+ 'PROP_LESS_THAN_OR_EQUAL',
30
+ ]);
31
+ /**
32
+ * LaunchDarkly attribute → Quonfig property name. The context kind becomes a
33
+ * namespace prefix (plan §5.1: `organization` + `plan` → `organization.plan`),
34
+ * defaulting to `user`. A nested JSON-pointer attribute (`/address/city`)
35
+ * becomes a dotted path (`address.city`).
36
+ */
37
+ export function normalizeAttribute(contextKind, attribute) {
38
+ const kind = contextKind && contextKind.length > 0 ? contextKind : 'user';
39
+ const dotted = attribute.replace(/^\//, '').replaceAll('/', '.');
40
+ return `${kind}.${dotted}`;
41
+ }
42
+ /** Build a `valueToMatch` typed value for a non-presence, non-segment clause. */
43
+ function valueToMatchFor(operator, clause, ctx) {
44
+ if (STRING_LIST_OPERATORS.has(operator)) {
45
+ return { type: 'string_list', value: clause.values.map(String) };
46
+ }
47
+ if (operator === 'IN_SEG' || operator === 'NOT_IN_SEG') {
48
+ if (clause.values.length > 1) {
49
+ ctx.report.add('skipped-rule', ctx.sourceKey, `segmentMatch clause listed ${clause.values.length} segments; Quonfig IN_SEG matches one — only "${String(clause.values[0])}" kept, the rest dropped`);
50
+ }
51
+ return { type: 'string', value: String(clause.values[0] ?? '') };
52
+ }
53
+ if (operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') {
54
+ if (clause.values.length > 1) {
55
+ ctx.report.add('skipped-rule', ctx.sourceKey, `regex clause listed ${clause.values.length} patterns; Quonfig PROP_MATCHES takes one — only the first kept`);
56
+ }
57
+ return { type: 'string', value: String(clause.values[0] ?? '') };
58
+ }
59
+ if (NUMERIC_OPERATORS.has(operator)) {
60
+ const raw = clause.values[0];
61
+ const num = typeof raw === 'number' ? raw : Number(raw);
62
+ return { type: Number.isInteger(num) ? 'int' : 'double', value: num };
63
+ }
64
+ if (operator === 'PROP_BEFORE' || operator === 'PROP_AFTER') {
65
+ const raw = clause.values[0];
66
+ // LD before/after take an epoch-ms number or an RFC3339 string; the SDK's
67
+ // dateToMillis accepts both, so pass the native shape straight through.
68
+ return typeof raw === 'number' ? { type: 'int', value: raw } : { type: 'string', value: String(raw) };
69
+ }
70
+ // Semver operators — the SDK requires a string value.
71
+ return { type: 'string', value: String(clause.values[0] ?? '') };
72
+ }
73
+ /**
74
+ * Map one provider clause to a Quonfig criterion, or `null` when the clause's
75
+ * operator has no v1 mapping (D3 — negated comparisons, applicationVersion…).
76
+ * A null result means the whole containing rule must be dropped: keeping a
77
+ * partial AND would silently broaden targeting.
78
+ */
79
+ export function clauseToCriterion(clause, ctx) {
80
+ const mapping = mapLaunchDarklyOperator(clause.op, Boolean(clause.negate));
81
+ if ('skip' in mapping) {
82
+ ctx.report.add('skipped-rule', ctx.sourceKey, `clause on "${clause.attribute}" skipped: ${mapping.reason}`);
83
+ return null;
84
+ }
85
+ const { operator } = mapping;
86
+ const propertyName = normalizeAttribute(clause.contextKind, clause.attribute);
87
+ const criterion = { operator, propertyName };
88
+ criterion.valueToMatch = valueToMatchFor(operator, clause, ctx);
89
+ return criterion;
90
+ }
91
+ /**
92
+ * Build a Quonfig rule from a provider rule's clauses (AND semantics) plus its
93
+ * served value. Returns `null` when any clause cannot be mapped — the rule is
94
+ * dropped wholesale and reported, rather than silently broadened.
95
+ */
96
+ export function buildRule(clauses, value, ctx) {
97
+ const criteria = [];
98
+ for (const clause of clauses) {
99
+ const criterion = clauseToCriterion(clause, ctx);
100
+ if (criterion === null)
101
+ return null;
102
+ criteria.push(criterion);
103
+ }
104
+ // A rule with no clauses matches everyone — represent that as ALWAYS_TRUE.
105
+ return { criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE], value };
106
+ }
107
+ /** Resolve a variation index to its typed Quonfig value. */
108
+ function variationValue(index, ctx) {
109
+ const raw = ctx.variationValues[index];
110
+ return toQuonfigValue(raw, ctx.valueType);
111
+ }
112
+ /**
113
+ * Convert a provider rollout to a Quonfig `weighted_values`. The experiment
114
+ * framing (`kind: "experiment"` + `seed`) is dropped + reported (plan §5.4),
115
+ * and every rollout is recorded as re-bucketing (LD and Quonfig hash
116
+ * differently, so post-migration bucket assignments differ).
117
+ */
118
+ export function rolloutToWeightedValues(rollout, ctx) {
119
+ if (rollout.kind === 'experiment') {
120
+ ctx.report.add('dropped-experiment-metadata', ctx.sourceKey, `experiment rollout converted to a plain weighted_values; experiment kind${rollout.seed === undefined ? '' : ` and seed ${rollout.seed}`} dropped`);
121
+ }
122
+ const hashByPropertyName = rollout.bucketBy
123
+ ? normalizeAttribute(rollout.contextKind, rollout.bucketBy)
124
+ : `${rollout.contextKind && rollout.contextKind.length > 0 ? rollout.contextKind : 'user'}.key`;
125
+ ctx.report.add('rebucketed-rollout', ctx.sourceKey, `percentage rollout hashed on "${hashByPropertyName}" — LaunchDarkly and Quonfig bucket differently, so users will be re-bucketed`);
126
+ return {
127
+ type: 'weighted_values',
128
+ value: {
129
+ hashByPropertyName,
130
+ weightedValues: rollout.variations.map((wv) => ({
131
+ value: variationValue(wv.variation, ctx),
132
+ weight: wv.weight,
133
+ })),
134
+ },
135
+ };
136
+ }
137
+ /** Resolve a `variation`-or-`rollout` pointer (rule served value / fallthrough). */
138
+ export function servedValue(voR, ctx) {
139
+ if (voR.rollout)
140
+ return rolloutToWeightedValues(voR.rollout, ctx);
141
+ return variationValue(voR.variation ?? 0, ctx);
142
+ }
143
+ /**
144
+ * Collapse a single LaunchDarkly per-environment flag state into an ordered,
145
+ * first-match-wins Quonfig rule list.
146
+ *
147
+ * - `on: false` → one ALWAYS_TRUE rule serving the `offVariation` value.
148
+ * - `on: true` → individual/context targets become leading PROP_IS_ONE_OF
149
+ * rules; `rules[]` translate in order; `fallthrough` becomes a trailing
150
+ * ALWAYS_TRUE rule. Prerequisites are dropped + reported (no Quonfig
151
+ * cross-flag dependency operator in v1).
152
+ */
153
+ export function collapseEnvironment(env, ctx) {
154
+ if (!env.on) {
155
+ return [{ criteria: [ALWAYS_TRUE], value: variationValue(env.offVariation ?? 0, ctx) }];
156
+ }
157
+ const rules = [];
158
+ if (env.prerequisites && env.prerequisites.length > 0) {
159
+ ctx.report.add('dropped-prerequisite', ctx.sourceKey, `${env.prerequisites.length} prerequisite(s) dropped (${env.prerequisites
160
+ .map((p) => p.key)
161
+ .join(', ')}) — Quonfig has no cross-flag dependency operator`);
162
+ }
163
+ // Individual + context targets evaluate before rules in LaunchDarkly, so
164
+ // they become leading rules. Lossy: the LD UI affordance is gone (plan §5.4).
165
+ const allTargets = [...(env.targets ?? []), ...(env.contextTargets ?? [])];
166
+ for (const target of allTargets) {
167
+ if (target.values.length === 0)
168
+ continue;
169
+ const kind = target.contextKind && target.contextKind.length > 0 ? target.contextKind : 'user';
170
+ rules.push({
171
+ criteria: [
172
+ {
173
+ operator: 'PROP_IS_ONE_OF',
174
+ propertyName: `${kind}.key`,
175
+ valueToMatch: { type: 'string_list', value: [...target.values] },
176
+ },
177
+ ],
178
+ value: variationValue(target.variation, ctx),
179
+ });
180
+ ctx.report.add('individual-target-as-rule', ctx.sourceKey, `${target.values.length} ${kind} target(s) converted to a leading PROP_IS_ONE_OF rule on ${kind}.key`);
181
+ }
182
+ for (const rule of env.rules ?? []) {
183
+ const value = rule.rollout === undefined ? variationValue(rule.variation ?? 0, ctx) : rolloutToWeightedValues(rule.rollout, ctx);
184
+ const built = buildRule(rule.clauses, value, ctx);
185
+ if (built !== null)
186
+ rules.push(built);
187
+ }
188
+ if (env.fallthrough) {
189
+ rules.push({ criteria: [ALWAYS_TRUE], value: servedValue(env.fallthrough, ctx) });
190
+ }
191
+ return rules;
192
+ }