@quonfig/cli 0.0.48 → 0.0.50

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.
@@ -43,14 +43,34 @@ const renderEnvironmentMap = (entries) => {
43
43
  const rows = sorted.map((e) => `| ${e.sourceName} | ${e.quonfigName} |`);
44
44
  return [header, '', '| Source | Quonfig |', '| --- | --- |', ...rows].join('\n');
45
45
  };
46
- const renderIdentifierMap = (map) => {
46
+ const renderIdentifierMap = (map, maintainerMap) => {
47
47
  const header = '## Identifier map';
48
+ const sections = [];
48
49
  const keys = Object.keys(map);
49
- if (keys.length === 0)
50
+ if (keys.length > 0) {
51
+ const sorted = [...keys].sort();
52
+ const rows = sorted.map((k) => `| \`${k}\` | \`${map[k]}\` |`);
53
+ sections.push(['### Legacy keys', '', '| Legacy key | Quonfig key |', '| --- | --- |', ...rows].join('\n'));
54
+ }
55
+ const maintainerIds = maintainerMap ? Object.keys(maintainerMap) : [];
56
+ if (maintainerIds.length > 0) {
57
+ const sorted = [...maintainerIds].sort();
58
+ const rows = sorted.map((id) => `| \`${id}\` | ${maintainerMap[id]} |`);
59
+ sections.push([
60
+ '### Maintainer IDs',
61
+ '',
62
+ `Resolved **${sorted.length}** maintainer ID${sorted.length === 1 ? '' : 's'} via the LaunchDarkly /members ` +
63
+ `endpoint. Use this table to read 'Dropped maintainer metadata' below — each \`maintainerId\` there maps to the ` +
64
+ `email here.`,
65
+ '',
66
+ '| maintainerId | email |',
67
+ '| --- | --- |',
68
+ ...rows,
69
+ ].join('\n'));
70
+ }
71
+ if (sections.length === 0)
50
72
  return `${header}\n\n${NONE}`;
51
- const sorted = [...keys].sort();
52
- const rows = sorted.map((k) => `| \`${k}\` | \`${map[k]}\` |`);
53
- return [header, '', '| Legacy key | Quonfig key |', '| --- | --- |', ...rows].join('\n');
73
+ return [header, '', ...sections].join('\n\n');
54
74
  };
55
75
  const renderDroppedOverrides = (dropped) => {
56
76
  if (!dropped || dropped.total === 0)
@@ -138,13 +158,96 @@ const CONVERSION_NOTE_HEADINGS = {
138
158
  'dropped-custom-properties': 'Dropped custom properties',
139
159
  'dropped-experiment-metadata': 'Dropped experiment metadata',
140
160
  'dropped-maintainer': 'Dropped maintainer metadata',
141
- 'dropped-mobile-key-availability': 'Dropped mobile-key availability',
161
+ 'dropped-mobile-key-now-server-only': 'Dropped mobile-key availability (now server-only)',
162
+ 'dropped-mobile-key-still-visible': 'Dropped mobile-key availability (still client-visible)',
142
163
  'dropped-prerequisite': 'Dropped prerequisites',
143
164
  'dropped-private-attribute': 'Dropped private attributes',
144
165
  'individual-target-as-rule': 'Individual targets converted to rules',
145
166
  'skipped-rule': 'Skipped rules (unsupported operators)',
146
167
  'unexportable-segment-membership': 'Unexportable segment membership',
147
168
  };
169
+ const SIGNAL_SPECS = {
170
+ 'dropped-mobile-key-now-server-only': {
171
+ render: (count, keys) => `**${count} flag${count === 1 ? '' : 's'} ${count === 1 ? 'will' : 'will'} stop reaching mobile clients** ` +
172
+ `(${keys.map((k) => `\`${k}\``).join(', ')}). LaunchDarkly had \`usingMobileKey:true\` with ` +
173
+ `\`usingEnvironmentId:false\` — these were mobile-only and won't be visible to mobile SDKs post-migration. ` +
174
+ `Rebuild client-side access by hand before cutover.`,
175
+ },
176
+ 'dropped-prerequisite': {
177
+ render: (count, keys) => `**${count} flag${count === 1 ? '' : 's'} lost cross-flag dependencies** (${keys.map((k) => `\`${k}\``).join(', ')}). ` +
178
+ `The Quonfig copy now serves its variations independently of its parent flag. ` +
179
+ `Review §'Dropped prerequisites' below.`,
180
+ },
181
+ 'rebucketed-rollout': {
182
+ render: (count) => `**${count} flag${count === 1 ? '' : 's'} will re-bucket users.** Coordinate comms or drain affected ` +
183
+ `rollouts — see 'Users will be re-bucketed' below.`,
184
+ },
185
+ 'skipped-rule': {
186
+ render: (count, keys) => `**${count} flag${count === 1 ? '' : 's'} had rules skipped** (${keys.map((k) => `\`${k}\``).join(', ')}) — ` +
187
+ `a clause used an unsupported operator. Rebuild the rule by hand before cutover.`,
188
+ },
189
+ 'unexportable-segment-membership': {
190
+ render: (count, keys) => `**${count} segment${count === 1 ? '' : 's'} ${count === 1 ? 'has' : 'have'} missing membership** (${keys
191
+ .map((k) => `\`${k}\``)
192
+ .join(', ')}). Re-author or sync from your IdP before cutover.`,
193
+ },
194
+ };
195
+ const SIGNAL_ORDER = [
196
+ 'dropped-mobile-key-now-server-only',
197
+ 'dropped-prerequisite',
198
+ 'rebucketed-rollout',
199
+ 'unexportable-segment-membership',
200
+ 'skipped-rule',
201
+ ];
202
+ const MAX_EXAMPLE_KEYS = 3;
203
+ const groupKeysByCategory = (notes) => {
204
+ const map = new Map();
205
+ for (const note of notes) {
206
+ const list = map.get(note.category) ?? [];
207
+ list.push(note.key);
208
+ map.set(note.category, list);
209
+ }
210
+ return map;
211
+ };
212
+ const renderBeforeYouCutOver = (notes) => {
213
+ if (!notes || notes.length === 0)
214
+ return null;
215
+ const byCategory = groupKeysByCategory(notes);
216
+ const items = [];
217
+ for (const category of SIGNAL_ORDER) {
218
+ const keys = byCategory.get(category);
219
+ if (!keys || keys.length === 0)
220
+ continue;
221
+ const sortedKeys = [...keys].sort((a, b) => a.localeCompare(b));
222
+ const example = sortedKeys.slice(0, MAX_EXAMPLE_KEYS);
223
+ const truncated = sortedKeys.length > MAX_EXAMPLE_KEYS ? [...example, '…'] : example;
224
+ items.push(SIGNAL_SPECS[category].render(sortedKeys.length, truncated));
225
+ }
226
+ if (items.length === 0)
227
+ return null;
228
+ const lines = [
229
+ '## Before you cut over',
230
+ '',
231
+ `${items.length} thing${items.length === 1 ? ' needs' : 's need'} a human decision before flipping the SDK:`,
232
+ '',
233
+ ];
234
+ items.forEach((item, idx) => {
235
+ lines.push(`${idx + 1}. ${item}`);
236
+ });
237
+ const ignoreParts = [];
238
+ const maintainerCount = byCategory.get('dropped-maintainer')?.length ?? 0;
239
+ if (maintainerCount > 0) {
240
+ ignoreParts.push(`dropped maintainer metadata (${maintainerCount} entries — git authorship replaces it)`);
241
+ }
242
+ const mobileKeyCount = byCategory.get('dropped-mobile-key-still-visible')?.length ?? 0;
243
+ if (mobileKeyCount > 0) {
244
+ ignoreParts.push(`dropped mobile-key availability (${mobileKeyCount} entries — all still client-visible via usingEnvironmentId)`);
245
+ }
246
+ if (ignoreParts.length > 0) {
247
+ lines.push('', `You can ignore: ${ignoreParts.join(', ')}.`);
248
+ }
249
+ return lines.join('\n');
250
+ };
148
251
  const renderRebucketedRollouts = (notes) => {
149
252
  const rollouts = (notes ?? []).filter((n) => n.category === 'rebucketed-rollout');
150
253
  if (rollouts.length === 0)
@@ -163,7 +266,77 @@ const renderRebucketedRollouts = (notes) => {
163
266
  }
164
267
  return lines.join('\n');
165
268
  };
166
- const renderConversionNotes = (notes) => {
269
+ /**
270
+ * Categories that are pure informational noise (drowned out the signal in the
271
+ * 172-note LD migration report — see qfg-ve5w). Render as a 1-line rollup +
272
+ * collapsible `<details>` block instead of N top-level bullets.
273
+ */
274
+ const COLLAPSED_CATEGORIES = {
275
+ 'dropped-maintainer': (count) => `${count} flag${count === 1 ? '' : 's'} had a maintainer ID dropped. Intentional — Quonfig authorship lives in ` +
276
+ `git history. No action required.`,
277
+ 'dropped-mobile-key-still-visible': (count) => `${count} flag${count === 1 ? '' : 's'} had \`usingMobileKey:true\` collapsed into the single \`sendToClientSdk\` ` +
278
+ `boolean. Each flag is still client-visible via \`usingEnvironmentId\`. No action required.`,
279
+ };
280
+ /**
281
+ * Render the `dropped-prerequisite` subsection (qfg-nb4n): one multi-line entry
282
+ * per child flag with nested parent bullets naming each variation index, plus
283
+ * a trailing inverted view that lists each orphaned parent and its downstream
284
+ * consumers. Returns the rendered lines (without the `### …` header — that is
285
+ * pushed by the caller so the section grouping stays consistent).
286
+ */
287
+ const renderDroppedPrerequisiteBody = (notes) => {
288
+ const sorted = [...notes].sort((a, b) => a.key.localeCompare(b.key));
289
+ const lines = [];
290
+ // Forward view: child flag → parents.
291
+ for (const note of sorted) {
292
+ const edges = note.prerequisites;
293
+ if (!edges || edges.length === 0) {
294
+ // Backward-compat fallback when a future caller forgets the structured payload.
295
+ lines.push(`- \`${note.key}\` — ${note.detail}`);
296
+ continue;
297
+ }
298
+ lines.push(`- \`${note.key}\` evaluated independently of its ${edges.length} parent${edges.length === 1 ? '' : 's'}:`);
299
+ for (const edge of edges) {
300
+ lines.push(` - was gated on \`${edge.parentKey}\` = variation ${edge.variation}`);
301
+ }
302
+ lines.push(' To preserve the gate, add a leading rule matching the same parent state, or wrap reads in app code.');
303
+ }
304
+ // Inverted view: parent → children that depended on it.
305
+ const parentToChildren = new Map();
306
+ for (const note of sorted) {
307
+ for (const edge of note.prerequisites ?? []) {
308
+ const set = parentToChildren.get(edge.parentKey) ?? new Set();
309
+ set.add(note.key);
310
+ parentToChildren.set(edge.parentKey, set);
311
+ }
312
+ }
313
+ if (parentToChildren.size > 0) {
314
+ lines.push('', '#### Orphaned parent flags (inverted view)', '');
315
+ const parents = [...parentToChildren.keys()].sort((a, b) => a.localeCompare(b));
316
+ for (const parent of parents) {
317
+ const children = [...(parentToChildren.get(parent) ?? [])].sort((a, b) => a.localeCompare(b));
318
+ const childList = children.map((c) => `\`${c}\``).join(', ');
319
+ lines.push(`- **\`${parent}\`** is now an orphaned dependency. Downstream flags that depended on it: ${childList}.`);
320
+ }
321
+ }
322
+ return lines;
323
+ };
324
+ /**
325
+ * qfg-l8uz: substitute every `<hex maintainerId>` in a dropped-maintainer
326
+ * detail with `<id> (<email>)` so the per-flag rollup reads as a human label
327
+ * instead of an opaque hex string. No-op when the map is empty/missing or the
328
+ * ID isn't in it (team-key drops, partial member visibility) — the unsubstituted
329
+ * line is still informative.
330
+ */
331
+ const decorateMaintainerDetail = (detail, maintainerMap) => {
332
+ if (!maintainerMap)
333
+ return detail;
334
+ return detail.replaceAll(/[\da-f]{24}/g, (match) => {
335
+ const email = maintainerMap[match];
336
+ return email ? `${match} (${email})` : match;
337
+ });
338
+ };
339
+ const renderConversionNotes = (notes, maintainerMap) => {
167
340
  // `skipped-config` (own section) and `rebucketed-rollout` (own section) are
168
341
  // rendered elsewhere — everything else is grouped by category here.
169
342
  const grouped = (notes ?? []).filter((n) => n.category !== 'rebucketed-rollout' && n.category !== 'skipped-config');
@@ -182,12 +355,102 @@ const renderConversionNotes = (notes) => {
182
355
  continue;
183
356
  lines.push('', `### ${CONVERSION_NOTE_HEADINGS[category]}`, '');
184
357
  const sorted = [...forCategory].sort((a, b) => a.key.localeCompare(b.key));
358
+ const rollup = COLLAPSED_CATEGORIES[category];
359
+ if (rollup) {
360
+ lines.push(rollup(sorted.length), '', '<details><summary>Per-flag list</summary>', '');
361
+ for (const note of sorted) {
362
+ const detail = category === 'dropped-maintainer' ? decorateMaintainerDetail(note.detail, maintainerMap) : note.detail;
363
+ lines.push(`- \`${note.key}\` — ${detail}`);
364
+ }
365
+ lines.push('', '</details>');
366
+ continue;
367
+ }
368
+ if (category === 'dropped-prerequisite') {
369
+ lines.push(...renderDroppedPrerequisiteBody(sorted));
370
+ continue;
371
+ }
185
372
  for (const note of sorted) {
186
373
  lines.push(`- \`${note.key}\` — ${note.detail}`);
187
374
  }
188
375
  }
189
376
  return lines.join('\n');
190
377
  };
378
+ /**
379
+ * Maps each lossy conversion-note category to a Follow-up checklist bucket plus
380
+ * a 1-line remediation hint. Categories not listed here (or `skipped-config` /
381
+ * housekeeping categories like `dropped-maintainer`) are intentionally not
382
+ * surfaced as checklist items — they get their own dedicated report sections
383
+ * or are pure-metadata losses that don't require human follow-up.
384
+ */
385
+ const FOLLOW_UP_BUCKETS = {
386
+ 'dropped-mobile-key-now-server-only': {
387
+ bucket: 'mustFixBeforeCutover',
388
+ hint: (key) => `\`${key}\` — flag was mobile-only in LaunchDarkly (usingMobileKey:true, usingEnvironmentId:false) and will not reach mobile SDKs after migration; restore client-side access by hand.`,
389
+ },
390
+ 'dropped-prerequisite': {
391
+ bucket: 'mustFixBeforeCutover',
392
+ hint: (key) => `\`${key}\` — restore the dropped prerequisite gate manually; Quonfig v1 has no cross-flag dependency operator.`,
393
+ },
394
+ 'individual-target-as-rule': {
395
+ bucket: 'reviewPostCutover',
396
+ hint: (key) => `\`${key}\` — individual user targets are now a leading rule; the LaunchDarkly targets pane affordance is gone.`,
397
+ },
398
+ 'rebucketed-rollout': {
399
+ bucket: 'reviewPostCutover',
400
+ hint: (key) => `\`${key}\` — percentage rollout will re-bucket users post-migration; communicate before cutover.`,
401
+ },
402
+ 'skipped-rule': {
403
+ bucket: 'mustFixBeforeCutover',
404
+ hint: (key) => `\`${key}\` — a rule clause used an unsupported operator and was skipped; rebuild the rule by hand.`,
405
+ },
406
+ 'unexportable-segment-membership': {
407
+ bucket: 'mustFixBeforeCutover',
408
+ hint: (key) => `\`${key}\` — segment shell migrated but membership is empty (LD big/synced segment); repopulate members before cutover.`,
409
+ },
410
+ };
411
+ /**
412
+ * Folds conversion notes into a followUp checklist. De-duplicates by hint
413
+ * string so a flag with two dropped prerequisites doesn't produce two
414
+ * identical checkbox lines.
415
+ */
416
+ export const deriveFollowUpFromConversionNotes = (base, notes) => {
417
+ const mustFix = new Set(base.mustFixBeforeCutover);
418
+ const review = new Set(base.reviewPostCutover);
419
+ for (const note of notes ?? []) {
420
+ const mapping = FOLLOW_UP_BUCKETS[note.category];
421
+ if (!mapping)
422
+ continue;
423
+ const line = mapping.hint(note.key);
424
+ if (mapping.bucket === 'mustFixBeforeCutover')
425
+ mustFix.add(line);
426
+ else
427
+ review.add(line);
428
+ }
429
+ return { mustFixBeforeCutover: [...mustFix], reviewPostCutover: [...review] };
430
+ };
431
+ /**
432
+ * Static, content-stable appendix describing v1 behavioral gaps between
433
+ * LaunchDarkly and Quonfig (plan §5.4 / qfg-ox7m). Documentation, not data —
434
+ * does not vary across runs. Rendered only when source === 'launchdarkly'.
435
+ */
436
+ const LAUNCHDARKLY_BEHAVIORAL_DIFFERENCES_APPENDIX = [
437
+ '## Behavioral differences post-cutover (independent of this import)',
438
+ '',
439
+ '- LaunchDarkly evaluates targets/contextTargets *before* rules. Quonfig has no',
440
+ ' individual-target primitive; user/context targets are now a leading PROP_IS_ONE_OF',
441
+ ' rule. Adding/removing individuals is now a config edit (qfg set / web UI), not a',
442
+ ' separate targeting UI pane.',
443
+ "- LaunchDarkly's 'off' toggle disappears. A flag is on iff at least one rule",
444
+ ' matches; there is no global kill-switch unless you author one as a top rule.',
445
+ '- offVariation does not exist in Quonfig. The off-state behavior of any flag',
446
+ ' that was on=false in LD has been baked into a single ALWAYS_TRUE rule serving',
447
+ ' that variation.',
448
+ '- Mobile SDK keys: LaunchDarkly distinguished mobile-key vs environment-id',
449
+ ' availability. Quonfig has one sendToClientSdk bool — your iOS/Android and',
450
+ ' browser-JS SDKs now share the same client-visibility setting.',
451
+ '- Maintainer is no longer a field on flags. `git log path/to/flag.json` is the',
452
+ ' new source of truth.',
453
+ ].join('\n');
191
454
  const renderFollowUp = (followUp) => {
192
455
  const must = followUp.mustFixBeforeCutover.length === 0
193
456
  ? NONE
@@ -212,7 +475,11 @@ export const buildMigrationReport = (data) => {
212
475
  if (data.dryRun) {
213
476
  sections.push('> **DRY RUN** — no files were written to the workspace during this run.');
214
477
  }
215
- sections.push(`# Migration Report (from ${data.source})`, `_Reflects only the changes produced by this run. Re-running overwrites this file._`, renderCounts(data.counts), renderCleanMappings(data.cleanMappings), renderLossyMappings(data.lossyMappings), renderUnsupported(data.unsupportedFeatures), renderEnvironmentMap(data.environmentMap), renderIdentifierMap(data.identifierMap));
478
+ sections.push(`# Migration Report (from ${data.source})`, `_Reflects only the changes produced by this run. Re-running overwrites this file._`);
479
+ const beforeYouCutOver = renderBeforeYouCutOver(data.conversionNotes);
480
+ if (beforeYouCutOver !== null)
481
+ sections.push(beforeYouCutOver);
482
+ sections.push(renderCounts(data.counts), renderCleanMappings(data.cleanMappings), renderLossyMappings(data.lossyMappings), renderUnsupported(data.unsupportedFeatures), renderEnvironmentMap(data.environmentMap), renderIdentifierMap(data.identifierMap, data.maintainerMap));
216
483
  const dropped = renderDroppedOverrides(data.droppedOverrides);
217
484
  if (dropped !== null)
218
485
  sections.push(dropped);
@@ -228,10 +495,13 @@ export const buildMigrationReport = (data) => {
228
495
  const rebucketed = renderRebucketedRollouts(data.conversionNotes);
229
496
  if (rebucketed !== null)
230
497
  sections.push(rebucketed);
231
- const conversionNotes = renderConversionNotes(data.conversionNotes);
498
+ const conversionNotes = renderConversionNotes(data.conversionNotes, data.maintainerMap);
232
499
  if (conversionNotes !== null)
233
500
  sections.push(conversionNotes);
234
501
  sections.push(renderFollowUp(data.followUp));
502
+ if (data.source === 'launchdarkly') {
503
+ sections.push(LAUNCHDARKLY_BEHAVIORAL_DIFFERENCES_APPENDIX);
504
+ }
235
505
  return sections.join('\n\n') + '\n';
236
506
  };
237
507
  export const migrationReportPath = (outputDir) => path.join(outputDir, 'MIGRATION_REPORT.md');
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { removeQfFromGitignore, writeImportState } from './import-state.js';
4
4
  import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles, } from './local-write.js';
5
- import { writeMigrationReport } from './migration-report.js';
5
+ import { deriveFollowUpFromConversionNotes, writeMigrationReport } from './migration-report.js';
6
6
  import { cloneAndStackPush, } from '../util/clone-and-stack-push.js';
7
7
  import { formatResult, validateWorkspace } from '../verify/validate.js';
8
8
  /**
@@ -95,6 +95,8 @@ export const pushMigrationToCloud = async (opts) => {
95
95
  acc.duplicateResolutions =
96
96
  resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
97
97
  const conversionNotes = opts.source.getConversionNotes?.() ?? null;
98
+ const environmentMap = opts.source.getEnvironmentMap?.() ?? null;
99
+ const maintainerMap = opts.source.getMaintainerMap?.() ?? null;
98
100
  const skippedTotal = (acc.skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
99
101
  const environmentsCount = opts.environments?.length ?? 0;
100
102
  const counts = buildMigrationCounts(livePaths, environmentsCount, skippedTotal);
@@ -102,10 +104,13 @@ export const pushMigrationToCloud = async (opts) => {
102
104
  const reportData = {
103
105
  ...opts.reportData,
104
106
  counts,
107
+ ...(environmentMap && environmentMap.length > 0 ? { environmentMap } : {}),
108
+ followUp: deriveFollowUpFromConversionNotes(opts.reportData.followUp, conversionNotes ?? undefined),
105
109
  ...(acc.coercedSentinels ? { coercedSentinels: acc.coercedSentinels } : {}),
106
110
  ...(conversionNotes && conversionNotes.length > 0 ? { conversionNotes } : {}),
107
111
  ...(acc.droppedOverrides ? { droppedOverrides: acc.droppedOverrides } : {}),
108
112
  ...(acc.duplicateResolutions ? { duplicateResolutions: acc.duplicateResolutions } : {}),
113
+ ...(maintainerMap && Object.keys(maintainerMap).length > 0 ? { maintainerMap } : {}),
109
114
  ...(acc.skippedConfigs ? { skippedConfigs: acc.skippedConfigs } : {}),
110
115
  };
111
116
  writeMigrationReport(dir, reportData);
@@ -26,8 +26,18 @@ export type ConversionNoteCategory =
26
26
  | 'dropped-maintainer'
27
27
  /** `customProperties` dropped — report-only in v1 (D6). */
28
28
  | 'dropped-custom-properties'
29
- /** clientSideAvailability mobile-key dimension dropped. */
30
- | 'dropped-mobile-key-availability'
29
+ /**
30
+ * `usingMobileKey:true` collapsed into Quonfig's single client-visibility bool,
31
+ * but the flag is still client-visible because `usingEnvironmentId` is also true.
32
+ * No action required — pure rollup noise.
33
+ */
34
+ | 'dropped-mobile-key-still-visible'
35
+ /**
36
+ * `usingMobileKey:true` but `usingEnvironmentId:false` — the flag was
37
+ * mobile-only in LD and will no longer reach mobile clients after migration.
38
+ * Must-fix before cutover.
39
+ */
40
+ | 'dropped-mobile-key-now-server-only'
31
41
  /** A percentage rollout that will re-bucket users post-migration (plan §5.4). */
32
42
  | 'rebucketed-rollout'
33
43
  /** A big/synced segment whose membership is not exportable via REST. */
@@ -40,6 +50,15 @@ export interface ConversionNote {
40
50
  detail: string;
41
51
  /** Source key the note is about (flag or segment key). */
42
52
  key: string;
53
+ /**
54
+ * Structured prereq edges on `dropped-prerequisite` notes — the renderer uses
55
+ * this to produce the inverted "orphaned parent → downstream consumers" view
56
+ * (qfg-nb4n). Absent on every other category.
57
+ */
58
+ prerequisites?: Array<{
59
+ parentKey: string;
60
+ variation: number;
61
+ }>;
43
62
  }
44
63
  /**
45
64
  * Collects conversion notes during a migration run. A provider source holds
@@ -49,7 +68,12 @@ export interface ConversionNote {
49
68
  export declare class ConversionReport {
50
69
  private readonly notes;
51
70
  get size(): number;
52
- add(category: ConversionNoteCategory, key: string, detail: string): void;
71
+ add(category: ConversionNoteCategory, key: string, detail: string, extras?: {
72
+ prerequisites?: Array<{
73
+ parentKey: string;
74
+ variation: number;
75
+ }>;
76
+ }): void;
53
77
  /** All notes, in insertion order. */
54
78
  all(): ConversionNote[];
55
79
  /** Notes of one category, in insertion order. */
@@ -19,8 +19,11 @@ export class ConversionReport {
19
19
  get size() {
20
20
  return this.notes.length;
21
21
  }
22
- add(category, key, detail) {
23
- this.notes.push({ category, detail, key });
22
+ add(category, key, detail, extras) {
23
+ const note = { category, detail, key };
24
+ if (extras?.prerequisites)
25
+ note.prerequisites = extras.prerequisites;
26
+ this.notes.push(note);
24
27
  }
25
28
  /** All notes, in insertion order. */
26
29
  all() {
@@ -155,11 +155,10 @@ export function collapseEnvironment(env, ctx) {
155
155
  return [{ criteria: [ALWAYS_TRUE], value: variationValue(env.offVariation ?? 0, ctx) }];
156
156
  }
157
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
- }
158
+ // Prerequisites are dropped (no cross-flag dependency operator in Quonfig v1)
159
+ // but their reporting is owned by the caller (translateFlag), which dedupes
160
+ // across environments and emits one enriched note per child flag (qfg-nb4n).
161
+ // collapseEnvironment intentionally stays silent here.
163
162
  // Individual + context targets evaluate before rules in LaunchDarkly, so
164
163
  // they become leading rules. Lossy: the LD UI affordance is gone (plan §5.4).
165
164
  const allTargets = [...(env.targets ?? []), ...(env.contextTargets ?? [])];
@@ -177,7 +176,10 @@ export function collapseEnvironment(env, ctx) {
177
176
  ],
178
177
  value: variationValue(target.variation, ctx),
179
178
  });
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`);
179
+ ctx.report.add('individual-target-as-rule', ctx.sourceKey, `${target.values.length} ${kind} key(s) inlined into a leading PROP_IS_ONE_OF rule on ${kind}.key. ` +
180
+ `**Trade-off:** you lost the LD 'targets' UI pane — adding/removing an individual now requires a ` +
181
+ `config edit (qfg set / web UI), not a separate UI. If this was used for beta-list-style toggling, ` +
182
+ `consider authoring a \`beta_user\` context attribute instead.`);
181
183
  }
182
184
  for (const rule of env.rules ?? []) {
183
185
  const value = rule.rollout === undefined ? variationValue(rule.variation ?? 0, ctx) : rolloutToWeightedValues(rule.rollout, ctx);
@@ -12,6 +12,17 @@ export interface QuonfigFile {
12
12
  deleted?: boolean;
13
13
  path: string;
14
14
  }
15
+ /**
16
+ * One row of the "Environment mapping table" in MIGRATION_REPORT.md. Sources
17
+ * implement `getEnvironmentMap()` to expose the source-name → Quonfig-name
18
+ * pairs they translated; the report renderer just formats the rows.
19
+ */
20
+ export interface EnvironmentMapEntry {
21
+ /** The Quonfig-side environment name (slugified key). */
22
+ quonfigName: string;
23
+ /** The user-facing source-system name (e.g. LD env display name). */
24
+ sourceName: string;
25
+ }
15
26
  export interface DroppedOverrideSummary {
16
27
  /** Per-envId → per-output-path count of dropped override sections. */
17
28
  byEnv: Record<string, Record<string, number>>;
@@ -106,6 +117,22 @@ export interface MigrationSource {
106
117
  * report so customers can review the loss.
107
118
  */
108
119
  getDroppedOverrides?(): DroppedOverrideSummary | null;
120
+ /**
121
+ * qfg-1t7m: source-name → Quonfig-name pairs for the "Environment mapping table"
122
+ * section of MIGRATION_REPORT.md. Returns the rows the source actually
123
+ * translated this run; callers thread these into report data. Sources that
124
+ * don't expose user-facing env names omit this and the table renders `_(none)_`.
125
+ */
126
+ getEnvironmentMap?(): EnvironmentMapEntry[] | null;
127
+ /**
128
+ * qfg-l8uz: maintainerId → email pairs for the report's "Identifier map"
129
+ * section. Sources that fetch a member directory (e.g. LD's /members) expose
130
+ * the lookup here so the report renderer can swap opaque IDs for readable
131
+ * emails and decorate the `dropped-maintainer` rollup. Null when the source
132
+ * couldn't fetch members (auth scope, hidden member data) or has none to
133
+ * surface — the rest of the report still renders.
134
+ */
135
+ getMaintainerMap?(): null | Record<string, string>;
109
136
  /**
110
137
  * Optional post-translate accumulator. Returns any configs that translate() soft-
111
138
  * skipped due to invalid source data (e.g. variant/valueType mismatch). Null when
@@ -10,7 +10,7 @@
10
10
  * limit, so we program against the headers: back off on 429 using
11
11
  * `X-Ratelimit-Reset` (epoch-ms), pre-throttle on `X-Ratelimit-Global-Remaining`.
12
12
  */
13
- import type { LDFlag, LDSegment, LDSnapshot } from './types.js';
13
+ import type { LDFlag, LDMember, LDSegment, LDSnapshot } from './types.js';
14
14
  export declare function setLaunchDarklyBaseUrl(url: string): void;
15
15
  export declare function selectLaunchDarklyBaseUrl(): string;
16
16
  export declare function applyLaunchDarklyBaseUrl(): void;
@@ -32,6 +32,16 @@ export declare class LaunchDarklyApiError extends Error {
32
32
  export declare function apiFetch(path: string, apiKey: string): Promise<unknown>;
33
33
  /** Probe used by `validateAuth` — a cheap authenticated call that 401s on a bad token. */
34
34
  export declare function fetchProjectEnvironments(apiKey: string, projectKey: string): Promise<string[]>;
35
+ /**
36
+ * Same call as `fetchProjectEnvironments` but returns the full `{key, name}`
37
+ * pairs. The display `name` is needed for the MIGRATION_REPORT.md "Environment
38
+ * mapping table" — the bare key alone hides the customer-facing env label
39
+ * (qfg-1t7m).
40
+ */
41
+ export declare function fetchProjectEnvironmentsDetailed(apiKey: string, projectKey: string): Promise<Array<{
42
+ key: string;
43
+ name: string;
44
+ }>>;
35
45
  export declare function fetchContextKinds(apiKey: string, projectKey: string): Promise<string[]>;
36
46
  /**
37
47
  * Full per-environment flag targeting. `?summary=0` plus repeated `?env=`
@@ -48,6 +58,17 @@ export declare function fetchContextKinds(apiKey: string, projectKey: string): P
48
58
  export declare function fetchFlags(apiKey: string, projectKey: string, envKeys: string[], opts?: {
49
59
  archived?: boolean;
50
60
  }): Promise<LDFlag[]>;
61
+ /**
62
+ * Account-wide member list — used to resolve flag `maintainerId` hex strings
63
+ * to readable emails for MIGRATION_REPORT.md (qfg-l8uz). Members live above
64
+ * projects in LD's hierarchy, so this call is project-independent.
65
+ *
66
+ * Some API tokens (project-scoped, or accounts with member visibility
67
+ * restrictions) can't read members and get a 403. Callers should treat the
68
+ * member map as best-effort and downgrade gracefully — the migration must not
69
+ * fail because we couldn't enrich the maintainer rollup.
70
+ */
71
+ export declare function fetchMembers(apiKey: string): Promise<LDMember[]>;
51
72
  /** Segments are a per-environment resource — caller loops env keys and de-dupes. */
52
73
  export declare function fetchSegmentsForEnv(apiKey: string, projectKey: string, envKey: string): Promise<LDSegment[]>;
53
74
  /**
@@ -134,9 +134,18 @@ async function paginate(firstPath, apiKey) {
134
134
  }
135
135
  /** Probe used by `validateAuth` — a cheap authenticated call that 401s on a bad token. */
136
136
  export async function fetchProjectEnvironments(apiKey, projectKey) {
137
- const items = await paginate(`/projects/${encodeURIComponent(projectKey)}/environments?limit=${PAGE_LIMIT}`, apiKey);
137
+ const items = await fetchProjectEnvironmentsDetailed(apiKey, projectKey);
138
138
  return items.map((e) => e.key);
139
139
  }
140
+ /**
141
+ * Same call as `fetchProjectEnvironments` but returns the full `{key, name}`
142
+ * pairs. The display `name` is needed for the MIGRATION_REPORT.md "Environment
143
+ * mapping table" — the bare key alone hides the customer-facing env label
144
+ * (qfg-1t7m).
145
+ */
146
+ export async function fetchProjectEnvironmentsDetailed(apiKey, projectKey) {
147
+ return paginate(`/projects/${encodeURIComponent(projectKey)}/environments?limit=${PAGE_LIMIT}`, apiKey);
148
+ }
140
149
  export async function fetchContextKinds(apiKey, projectKey) {
141
150
  const data = (await apiFetch(`/projects/${encodeURIComponent(projectKey)}/context-kinds`, apiKey));
142
151
  return Array.isArray(data.items) ? data.items.map((k) => k.key) : [];
@@ -161,6 +170,19 @@ export async function fetchFlags(apiKey, projectKey, envKeys, opts = {}) {
161
170
  params.set('archived', 'true');
162
171
  return paginate(`/flags/${encodeURIComponent(projectKey)}?${params}`, apiKey);
163
172
  }
173
+ /**
174
+ * Account-wide member list — used to resolve flag `maintainerId` hex strings
175
+ * to readable emails for MIGRATION_REPORT.md (qfg-l8uz). Members live above
176
+ * projects in LD's hierarchy, so this call is project-independent.
177
+ *
178
+ * Some API tokens (project-scoped, or accounts with member visibility
179
+ * restrictions) can't read members and get a 403. Callers should treat the
180
+ * member map as best-effort and downgrade gracefully — the migration must not
181
+ * fail because we couldn't enrich the maintainer rollup.
182
+ */
183
+ export async function fetchMembers(apiKey) {
184
+ return paginate(`/members?limit=${PAGE_LIMIT}`, apiKey);
185
+ }
164
186
  /** Segments are a per-environment resource — caller loops env keys and de-dupes. */
165
187
  export async function fetchSegmentsForEnv(apiKey, projectKey, envKey) {
166
188
  return paginate(`/segments/${encodeURIComponent(projectKey)}/${encodeURIComponent(envKey)}?limit=${PAGE_LIMIT}`, apiKey);