@quonfig/cli 0.0.49 → 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.
Files changed (35) hide show
  1. package/README.md +50 -48
  2. package/dist/commands/generate.js +21 -5
  3. package/dist/commands/info.d.ts +1 -0
  4. package/dist/commands/info.js +28 -1
  5. package/dist/commands/migrate.d.ts +1 -1
  6. package/dist/commands/migrate.js +16 -5
  7. package/dist/commands/pull.js +2 -2
  8. package/dist/init/templates.js +59 -12
  9. package/dist/migrate/local-write.js +11 -1
  10. package/dist/migrate/migration-report.d.ts +16 -5
  11. package/dist/migrate/migration-report.js +351 -20
  12. package/dist/migrate/push-to-cloud.js +6 -1
  13. package/dist/migrate/quonfig-target/report.d.ts +45 -4
  14. package/dist/migrate/quonfig-target/report.js +5 -2
  15. package/dist/migrate/quonfig-target/ruleset.js +8 -6
  16. package/dist/migrate/source.d.ts +27 -0
  17. package/dist/migrate/sources/flagsmith/api.d.ts +115 -0
  18. package/dist/migrate/sources/flagsmith/api.js +402 -0
  19. package/dist/migrate/sources/flagsmith/translate.d.ts +72 -0
  20. package/dist/migrate/sources/flagsmith/translate.js +657 -0
  21. package/dist/migrate/sources/flagsmith/types.d.ts +282 -0
  22. package/dist/migrate/sources/flagsmith/types.js +18 -0
  23. package/dist/migrate/sources/flagsmith.d.ts +62 -0
  24. package/dist/migrate/sources/flagsmith.js +380 -23
  25. package/dist/migrate/sources/launchdarkly/api.d.ts +22 -1
  26. package/dist/migrate/sources/launchdarkly/api.js +23 -1
  27. package/dist/migrate/sources/launchdarkly/translate.js +42 -2
  28. package/dist/migrate/sources/launchdarkly/types.d.ts +19 -0
  29. package/dist/migrate/sources/launchdarkly.js +75 -4
  30. package/dist/util/friction-log.d.ts +1 -0
  31. package/dist/util/friction-log.js +6 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.js +1 -1
  34. package/oclif.manifest.json +158 -160
  35. package/package.json +2 -2
@@ -25,22 +25,50 @@ This is a [Quonfig](https://quonfig.com) workspace repository. All configuration
25
25
  | \`schemas/\` | JSON Schemas that configs can reference for validation |
26
26
  | \`schemas-protected/\` | Admin-only schemas |
27
27
 
28
- ## Quick start
28
+ ## Quick start (works fully offline — no account required)
29
+
30
+ This workspace is just a git repo full of JSON files. You can edit those files
31
+ directly, validate them locally, and point any Quonfig SDK at this directory.
32
+ No login, no API call, no internet connection needed.
29
33
 
30
34
  \`\`\`bash
31
- # Validate the workspace
35
+ # 1. Edit a flag — see the sample files in feature-flags/ for the shape.
36
+ $EDITOR feature-flags/my.new.flag.json
37
+
38
+ # 2. Validate the workspace.
32
39
  qfg verify
33
40
 
34
- # Create a new feature flag
35
- qfg create my.new.flag --type=boolean-flag
41
+ # 3. (Optional) Generate TypeScript types for your app.
42
+ qfg generate
36
43
 
37
- # List all configs
38
- qfg list
44
+ # 4. Commit. The pre-commit hook re-runs qfg verify automatically.
45
+ git add . && git commit -m "Add my.new.flag"
39
46
  \`\`\`
40
47
 
48
+ ## Two ways to use this repo
49
+
50
+ **Open-source / fully local.** Edit JSON, run \`qfg verify\`, point your SDK
51
+ at this directory (\`datadir: "./quonfig"\`). Push the repo wherever you want
52
+ (GitHub, self-hosted Gitea, your own server). This is the no-account path.
53
+
54
+ **Hosted Quonfig.** If you also have a Quonfig account, server-side commands
55
+ like \`qfg push\`, \`qfg create\`, \`qfg list\`, \`qfg set-default\`, and
56
+ \`qfg get\` give you a UI, real-time SSE delivery to SDKs, evaluation
57
+ telemetry, and audit history. These all require \`qfg login\`.
58
+
59
+ ## Local-only commands (no login required)
60
+
61
+ | Command | What it does |
62
+ |---------|--------------|
63
+ | \`qfg init\` | Re-initialize this workspace (idempotent, refreshes templates) |
64
+ | \`qfg verify\` | Validate every JSON file against the schema and cross-file rules |
65
+ | \`qfg generate\` | Generate typed SDK client code from the configs in this dir |
66
+ | \`qfg config-schema\` | Print the canonical config schema (great for AI agents) |
67
+ | \`qfg migrate\` | Import flags from LaunchDarkly, Flagsmith, etc. |
68
+
41
69
  ## Schema
42
70
 
43
- Config files reference the hosted JSON Schema at <https://api.quonfig.com/schemas/v1/stored-config.json> via their \`$schema\` field. Editors that support JSON Schema will provide autocomplete and validation automatically.
71
+ Config files reference the hosted JSON Schema at <https://api.quonfig.com/schemas/v1/stored-config.json> via their \`$schema\` field. Editors that support JSON Schema will provide autocomplete and validation automatically. The schema URL is fetched once by your editor's LSP — the CLI and SDK don't need it.
44
72
 
45
73
  ## Validation
46
74
 
@@ -55,7 +83,24 @@ export function claudeMdTemplate() {
55
83
 
56
84
  This is a Quonfig workspace repository. Configuration is stored as JSON files in git.
57
85
 
58
- **After every change to this repo, run \`qfg verify\` to validate.**
86
+ ## How to work in this repo
87
+
88
+ **You edit JSON files directly.** Do not run \`qfg create\`, \`qfg set-default\`,
89
+ \`qfg set-rollout\`, \`qfg override\`, \`qfg push\`, \`qfg pull\`, \`qfg get\`,
90
+ or \`qfg list\` — those commands talk to a hosted Quonfig server. In a local
91
+ workspace, the source of truth is this git repo, so edit JSON in place.
92
+
93
+ The complete local-only toolkit:
94
+
95
+ | Command | When to use |
96
+ |---------|-------------|
97
+ | \`qfg verify\` | After every change. Validates schema + cross-file rules. |
98
+ | \`qfg config-schema\` | Print the canonical schema. Use this to look up field names, operator enums, value-type shapes before you write a new config. |
99
+ | \`qfg generate\` | Regenerate typed SDK client code (consumers ask for this; you usually won't need to run it unless asked). |
100
+ | Read existing files | Look at sibling JSON files in the same directory for the canonical shape before writing a new one. |
101
+
102
+ **After every change to this repo, run \`qfg verify\` to validate.** If it
103
+ fails, fix the JSON until it passes — do not ship a broken workspace.
59
104
 
60
105
  ## JSON Schema
61
106
 
@@ -144,10 +189,12 @@ This is a Quonfig workspace repository. See \`CLAUDE.md\` for the complete schem
144
189
 
145
190
  ## Key rules
146
191
 
147
- 1. **Always validate**: run \`qfg verify\` after any change to this repo.
148
- 2. Filename must match the \`key\` field (e.g. \`my.flag.json\` has \`"key": "my.flag"\`).
149
- 3. Files must be in the correct directory for their \`type\`.
150
- 4. See \`CLAUDE.md\` for the full list of value types, operators, and constraints.
192
+ 1. **Edit JSON files directly.** Do NOT use \`qfg create\`, \`qfg set-default\`, \`qfg push\`, \`qfg get\`, or \`qfg list\` — those require a hosted Quonfig account. The source of truth here is the git repo.
193
+ 2. **Always validate**: run \`qfg verify\` after any change to this repo.
194
+ 3. **Look before you leap**: read sibling JSON files for the canonical shape, and run \`qfg config-schema\` to confirm field names, operator enums, and value types before writing a new config.
195
+ 4. Filename must match the \`key\` field (e.g. \`my.flag.json\` has \`"key": "my.flag"\`).
196
+ 5. Files must be in the correct directory for their \`type\`.
197
+ 6. See \`CLAUDE.md\` for the full list of value types, operators, and constraints.
151
198
  `;
152
199
  }
153
200
  // ── Pre-commit hook ────────────────────────────────────────────────────
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { removeQfFromGitignore, writeImportState } from './import-state.js';
4
- import { writeMigrationReport } from './migration-report.js';
4
+ import { deriveFollowUpFromConversionNotes, writeMigrationReport } from './migration-report.js';
5
5
  import { detectDuplicateKeys } from './sources/launch/translate.js';
6
6
  import { MIGRATOR_IDENTITY, stackCommits } from '../util/clone-and-stack-push.js';
7
7
  import { runGit, spawnGit } from '../util/git-ops.js';
@@ -199,16 +199,21 @@ export const applyLocalMigration = async (opts) => {
199
199
  const skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
200
200
  const coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
201
201
  const conversionNotes = opts.source.getConversionNotes?.() ?? null;
202
+ const environmentMap = opts.source.getEnvironmentMap?.() ?? null;
203
+ const maintainerMap = opts.source.getMaintainerMap?.() ?? null;
202
204
  const duplicateResolutions = resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
203
205
  const skippedTotal = (skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
204
206
  const counts = buildMigrationCounts(livePaths, opts.environments.length, skippedTotal);
205
207
  const reportData = {
206
208
  ...opts.reportData,
207
209
  counts,
210
+ ...(environmentMap && environmentMap.length > 0 ? { environmentMap } : {}),
211
+ followUp: deriveFollowUpFromConversionNotes(opts.reportData.followUp, conversionNotes ?? undefined),
208
212
  ...(coercedSentinels ? { coercedSentinels } : {}),
209
213
  ...(conversionNotes && conversionNotes.length > 0 ? { conversionNotes } : {}),
210
214
  ...(droppedOverrides ? { droppedOverrides } : {}),
211
215
  ...(duplicateResolutions ? { duplicateResolutions } : {}),
216
+ ...(maintainerMap && Object.keys(maintainerMap).length > 0 ? { maintainerMap } : {}),
212
217
  ...(skippedConfigs ? { skippedConfigs } : {}),
213
218
  };
214
219
  writeMigrationReport(opts.localDir, reportData);
@@ -331,6 +336,8 @@ export const buildAuditFinalCommit = (opts) => ({
331
336
  const skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
332
337
  const coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
333
338
  const conversionNotes = opts.source.getConversionNotes?.() ?? null;
339
+ const environmentMap = opts.source.getEnvironmentMap?.() ?? null;
340
+ const maintainerMap = opts.source.getMaintainerMap?.() ?? null;
334
341
  const duplicateResolutions = resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
335
342
  removeQfFromGitignore(dir);
336
343
  writeImportState(dir, opts.importState);
@@ -339,10 +346,13 @@ export const buildAuditFinalCommit = (opts) => ({
339
346
  const reportData = {
340
347
  ...opts.reportData,
341
348
  counts,
349
+ ...(environmentMap && environmentMap.length > 0 ? { environmentMap } : {}),
350
+ followUp: deriveFollowUpFromConversionNotes(opts.reportData.followUp, conversionNotes ?? undefined),
342
351
  ...(coercedSentinels ? { coercedSentinels } : {}),
343
352
  ...(conversionNotes && conversionNotes.length > 0 ? { conversionNotes } : {}),
344
353
  ...(droppedOverrides ? { droppedOverrides } : {}),
345
354
  ...(duplicateResolutions ? { duplicateResolutions } : {}),
355
+ ...(maintainerMap && Object.keys(maintainerMap).length > 0 ? { maintainerMap } : {}),
346
356
  ...(skippedConfigs ? { skippedConfigs } : {}),
347
357
  };
348
358
  writeMigrationReport(dir, reportData);
@@ -1,6 +1,7 @@
1
1
  import type { IdentifierMap } from './identifier-map.js';
2
2
  import type { ConversionNote } from './quonfig-target/report.js';
3
- import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolutionSummary, SkippedConfigSummary } from './source.js';
3
+ import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolutionSummary, EnvironmentMapEntry, SkippedConfigSummary } from './source.js';
4
+ export type { EnvironmentMapEntry } from './source.js';
4
5
  export interface MigrationReportCounts {
5
6
  configsMigrated: number;
6
7
  environmentsMapped: number;
@@ -23,10 +24,6 @@ export interface UnsupportedFeature {
23
24
  feature: string;
24
25
  note?: string;
25
26
  }
26
- export interface EnvironmentMapEntry {
27
- quonfigName: string;
28
- sourceName: string;
29
- }
30
27
  export interface FollowUpChecklist {
31
28
  mustFixBeforeCutover: string[];
32
29
  reviewPostCutover: string[];
@@ -65,6 +62,14 @@ export interface MigrationReportData {
65
62
  followUp: FollowUpChecklist;
66
63
  identifierMap: IdentifierMap;
67
64
  lossyMappings: LossyMapping[];
65
+ /**
66
+ * qfg-l8uz: source maintainerId → email pairs. Rendered as a sub-table on
67
+ * `## Identifier map` so the reader sees who owned each dropped-maintainer
68
+ * note, and consumed by the renderer to decorate the dropped-maintainer
69
+ * rollup with emails. Optional/undefined when the source has no member
70
+ * lookup (or it failed) — both the table and the decoration just no-op.
71
+ */
72
+ maintainerMap?: null | Record<string, string>;
68
73
  /**
69
74
  * Configs soft-skipped by translate() because the source data was invalid
70
75
  * (e.g. variant/valueType mismatch). Null when nothing was skipped.
@@ -73,6 +78,12 @@ export interface MigrationReportData {
73
78
  source: string;
74
79
  unsupportedFeatures: UnsupportedFeature[];
75
80
  }
81
+ /**
82
+ * Folds conversion notes into a followUp checklist. De-duplicates by hint
83
+ * string so a flag with two dropped prerequisites doesn't produce two
84
+ * identical checkbox lines.
85
+ */
86
+ export declare const deriveFollowUpFromConversionNotes: (base: FollowUpChecklist, notes: ConversionNote[] | undefined) => FollowUpChecklist;
76
87
  export declare const buildMigrationReport: (data: MigrationReportData) => string;
77
88
  export declare const migrationReportPath: (outputDir: string) => string;
78
89
  export declare const writeMigrationReport: (outputDir: string, data: MigrationReportData) => string;
@@ -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)
@@ -90,20 +110,34 @@ const renderDuplicateResolutions = (resolved) => {
90
110
  }
91
111
  return lines.join('\n');
92
112
  };
93
- const renderCoercedSentinels = (coerced) => {
113
+ const FLAGSMITH_ENV_BUCKET_LABEL = {
114
+ '_all-envs': 'Override rows (all environments)',
115
+ '_cross-env': 'Cross-environment value-type divergence (no single env owns this)',
116
+ };
117
+ const renderCoercedSentinels = (coerced, source) => {
94
118
  if (!coerced || coerced.total === 0)
95
119
  return null;
96
120
  const envIds = Object.keys(coerced.byEnv).sort();
97
- const lines = [
98
- '## Coerced sentinel rule values',
99
- '',
100
- `Coerced **${coerced.total}** rule value(s) from Launch's "no value set yet" sentinel ({type:"string", value:""}) to the typed default for the surrounding config. The qfg-verify hook would otherwise reject these as type-mismatches and fail-stop the entire push. Affected envs: **${envIds.length}**. Review each and set a real default in the source system if needed.`,
101
- ];
121
+ const intro = source === 'flagsmith'
122
+ ? `Coerced **${coerced.total}** rule value(s) from Flagsmith's runtime semantics into a concrete served value. ` +
123
+ `Two flavors are bucketed here:\n\n` +
124
+ `1. **D-F1: \`enabled=false\` on non-boolean features.** Flagsmith would have ignored the stored value and ` +
125
+ `served your code's hardcoded default. Quonfig always serves a value, so the migrator emits the stored ` +
126
+ `\`feature_state_value\` and surfaces every affected flag below.\n` +
127
+ `2. **D-F5: cross-environment value-type divergence.** Flagsmith allowed the same flag to be a string in one ` +
128
+ `env and an int in another; Quonfig requires one \`valueType\` per flag. The migrator coerced to \`string\`.\n\n` +
129
+ `Review each entry below and set a real default in the source system if the value is wrong.`
130
+ : `Coerced **${coerced.total}** rule value(s) from Launch's "no value set yet" sentinel ` +
131
+ `({type:"string", value:""}) to the typed default for the surrounding config. The qfg-verify hook would ` +
132
+ `otherwise reject these as type-mismatches and fail-stop the entire push. Affected envs: ` +
133
+ `**${envIds.length}**. Review each and set a real default in the source system if needed.`;
134
+ const lines = ['## Coerced sentinel rule values', '', intro];
102
135
  for (const envId of envIds) {
103
136
  const perFlag = coerced.byEnv[envId];
104
137
  const totalForEnv = Object.values(perFlag).reduce((s, n) => s + n, 0);
105
138
  const flagCount = Object.keys(perFlag).length;
106
- lines.push('', `### env-${envId} — ${totalForEnv} coerced from ${flagCount} config(s)`, '');
139
+ const heading = FLAGSMITH_ENV_BUCKET_LABEL[envId] ?? `env-${envId}`;
140
+ lines.push('', `### ${heading} — ${totalForEnv} coerced from ${flagCount} config(s)`, '');
107
141
  const sorted = Object.keys(perFlag).sort();
108
142
  for (const flagPath of sorted) {
109
143
  lines.push(`- \`${flagPath}\` (${perFlag[flagPath]})`);
@@ -135,24 +169,112 @@ const renderSkippedConfigs = (skipped) => {
135
169
  */
136
170
  const CONVERSION_NOTE_HEADINGS = {
137
171
  'ai-config-skipped': 'AI Configs skipped (out of v1 scope)',
172
+ 'cross-env-value-type-coerced': 'Cross-environment value-type coerced to string',
138
173
  'dropped-custom-properties': 'Dropped custom properties',
139
174
  'dropped-experiment-metadata': 'Dropped experiment metadata',
140
175
  'dropped-maintainer': 'Dropped maintainer metadata',
141
- 'dropped-mobile-key-availability': 'Dropped mobile-key availability',
176
+ 'dropped-mobile-key-now-server-only': 'Dropped mobile-key availability (now server-only)',
177
+ 'dropped-mobile-key-still-visible': 'Dropped mobile-key availability (still client-visible)',
142
178
  'dropped-prerequisite': 'Dropped prerequisites',
143
179
  'dropped-private-attribute': 'Dropped private attributes',
180
+ 'enabled-false-non-boolean': 'enabled=false on non-boolean features',
181
+ 'identity-override-as-rule': 'Identity overrides converted to leading rules',
182
+ 'identity-traits-referenced': 'Identity traits referenced by segment rules',
144
183
  'individual-target-as-rule': 'Individual targets converted to rules',
145
184
  'skipped-rule': 'Skipped rules (unsupported operators)',
146
185
  'unexportable-segment-membership': 'Unexportable segment membership',
147
186
  };
148
- const renderRebucketedRollouts = (notes) => {
187
+ const SIGNAL_SPECS = {
188
+ 'dropped-mobile-key-now-server-only': {
189
+ render: (count, keys) => `**${count} flag${count === 1 ? '' : 's'} ${count === 1 ? 'will' : 'will'} stop reaching mobile clients** ` +
190
+ `(${keys.map((k) => `\`${k}\``).join(', ')}). LaunchDarkly had \`usingMobileKey:true\` with ` +
191
+ `\`usingEnvironmentId:false\` — these were mobile-only and won't be visible to mobile SDKs post-migration. ` +
192
+ `Rebuild client-side access by hand before cutover.`,
193
+ },
194
+ 'dropped-prerequisite': {
195
+ render: (count, keys) => `**${count} flag${count === 1 ? '' : 's'} lost cross-flag dependencies** (${keys.map((k) => `\`${k}\``).join(', ')}). ` +
196
+ `The Quonfig copy now serves its variations independently of its parent flag. ` +
197
+ `Review §'Dropped prerequisites' below.`,
198
+ },
199
+ 'rebucketed-rollout': {
200
+ render: (count) => `**${count} flag${count === 1 ? '' : 's'} will re-bucket users.** Coordinate comms or drain affected ` +
201
+ `rollouts — see 'Users will be re-bucketed' below.`,
202
+ },
203
+ 'skipped-rule': {
204
+ render: (count, keys) => `**${count} flag${count === 1 ? '' : 's'} had rules skipped** (${keys.map((k) => `\`${k}\``).join(', ')}) — ` +
205
+ `a clause used an unsupported operator. Rebuild the rule by hand before cutover.`,
206
+ },
207
+ 'unexportable-segment-membership': {
208
+ render: (count, keys) => `**${count} segment${count === 1 ? '' : 's'} ${count === 1 ? 'has' : 'have'} missing membership** (${keys
209
+ .map((k) => `\`${k}\``)
210
+ .join(', ')}). Re-author or sync from your IdP before cutover.`,
211
+ },
212
+ };
213
+ const SIGNAL_ORDER = [
214
+ 'dropped-mobile-key-now-server-only',
215
+ 'dropped-prerequisite',
216
+ 'rebucketed-rollout',
217
+ 'unexportable-segment-membership',
218
+ 'skipped-rule',
219
+ ];
220
+ const MAX_EXAMPLE_KEYS = 3;
221
+ const groupKeysByCategory = (notes) => {
222
+ const map = new Map();
223
+ for (const note of notes) {
224
+ const list = map.get(note.category) ?? [];
225
+ list.push(note.key);
226
+ map.set(note.category, list);
227
+ }
228
+ return map;
229
+ };
230
+ const renderBeforeYouCutOver = (notes) => {
231
+ if (!notes || notes.length === 0)
232
+ return null;
233
+ const byCategory = groupKeysByCategory(notes);
234
+ const items = [];
235
+ for (const category of SIGNAL_ORDER) {
236
+ const keys = byCategory.get(category);
237
+ if (!keys || keys.length === 0)
238
+ continue;
239
+ const sortedKeys = [...keys].sort((a, b) => a.localeCompare(b));
240
+ const example = sortedKeys.slice(0, MAX_EXAMPLE_KEYS);
241
+ const truncated = sortedKeys.length > MAX_EXAMPLE_KEYS ? [...example, '…'] : example;
242
+ items.push(SIGNAL_SPECS[category].render(sortedKeys.length, truncated));
243
+ }
244
+ if (items.length === 0)
245
+ return null;
246
+ const lines = [
247
+ '## Before you cut over',
248
+ '',
249
+ `${items.length} thing${items.length === 1 ? ' needs' : 's need'} a human decision before flipping the SDK:`,
250
+ '',
251
+ ];
252
+ items.forEach((item, idx) => {
253
+ lines.push(`${idx + 1}. ${item}`);
254
+ });
255
+ const ignoreParts = [];
256
+ const maintainerCount = byCategory.get('dropped-maintainer')?.length ?? 0;
257
+ if (maintainerCount > 0) {
258
+ ignoreParts.push(`dropped maintainer metadata (${maintainerCount} entries — git authorship replaces it)`);
259
+ }
260
+ const mobileKeyCount = byCategory.get('dropped-mobile-key-still-visible')?.length ?? 0;
261
+ if (mobileKeyCount > 0) {
262
+ ignoreParts.push(`dropped mobile-key availability (${mobileKeyCount} entries — all still client-visible via usingEnvironmentId)`);
263
+ }
264
+ if (ignoreParts.length > 0) {
265
+ lines.push('', `You can ignore: ${ignoreParts.join(', ')}.`);
266
+ }
267
+ return lines.join('\n');
268
+ };
269
+ const renderRebucketedRollouts = (notes, source) => {
149
270
  const rollouts = (notes ?? []).filter((n) => n.category === 'rebucketed-rollout');
150
271
  if (rollouts.length === 0)
151
272
  return null;
273
+ const sourceLabel = source === 'flagsmith' ? 'Flagsmith' : source === 'launchdarkly' ? 'LaunchDarkly' : source;
152
274
  const lines = [
153
275
  '## Users will be re-bucketed',
154
276
  '',
155
- `**${rollouts.length}** flag(s)/segment(s) use a percentage rollout. LaunchDarkly and Quonfig hash buckets ` +
277
+ `**${rollouts.length}** flag(s)/segment(s) use a percentage rollout. ${sourceLabel} and Quonfig hash buckets ` +
156
278
  `differently, so which users land in which bucket *will change* after migration — the rollout percentage is ` +
157
279
  `preserved, but individual user assignments are not. Review these before cutover so the re-bucketing is not a surprise:`,
158
280
  '',
@@ -163,7 +285,77 @@ const renderRebucketedRollouts = (notes) => {
163
285
  }
164
286
  return lines.join('\n');
165
287
  };
166
- const renderConversionNotes = (notes) => {
288
+ /**
289
+ * Categories that are pure informational noise (drowned out the signal in the
290
+ * 172-note LD migration report — see qfg-ve5w). Render as a 1-line rollup +
291
+ * collapsible `<details>` block instead of N top-level bullets.
292
+ */
293
+ const COLLAPSED_CATEGORIES = {
294
+ 'dropped-maintainer': (count) => `${count} flag${count === 1 ? '' : 's'} had a maintainer ID dropped. Intentional — Quonfig authorship lives in ` +
295
+ `git history. No action required.`,
296
+ 'dropped-mobile-key-still-visible': (count) => `${count} flag${count === 1 ? '' : 's'} had \`usingMobileKey:true\` collapsed into the single \`sendToClientSdk\` ` +
297
+ `boolean. Each flag is still client-visible via \`usingEnvironmentId\`. No action required.`,
298
+ };
299
+ /**
300
+ * Render the `dropped-prerequisite` subsection (qfg-nb4n): one multi-line entry
301
+ * per child flag with nested parent bullets naming each variation index, plus
302
+ * a trailing inverted view that lists each orphaned parent and its downstream
303
+ * consumers. Returns the rendered lines (without the `### …` header — that is
304
+ * pushed by the caller so the section grouping stays consistent).
305
+ */
306
+ const renderDroppedPrerequisiteBody = (notes) => {
307
+ const sorted = [...notes].sort((a, b) => a.key.localeCompare(b.key));
308
+ const lines = [];
309
+ // Forward view: child flag → parents.
310
+ for (const note of sorted) {
311
+ const edges = note.prerequisites;
312
+ if (!edges || edges.length === 0) {
313
+ // Backward-compat fallback when a future caller forgets the structured payload.
314
+ lines.push(`- \`${note.key}\` — ${note.detail}`);
315
+ continue;
316
+ }
317
+ lines.push(`- \`${note.key}\` evaluated independently of its ${edges.length} parent${edges.length === 1 ? '' : 's'}:`);
318
+ for (const edge of edges) {
319
+ lines.push(` - was gated on \`${edge.parentKey}\` = variation ${edge.variation}`);
320
+ }
321
+ lines.push(' To preserve the gate, add a leading rule matching the same parent state, or wrap reads in app code.');
322
+ }
323
+ // Inverted view: parent → children that depended on it.
324
+ const parentToChildren = new Map();
325
+ for (const note of sorted) {
326
+ for (const edge of note.prerequisites ?? []) {
327
+ const set = parentToChildren.get(edge.parentKey) ?? new Set();
328
+ set.add(note.key);
329
+ parentToChildren.set(edge.parentKey, set);
330
+ }
331
+ }
332
+ if (parentToChildren.size > 0) {
333
+ lines.push('', '#### Orphaned parent flags (inverted view)', '');
334
+ const parents = [...parentToChildren.keys()].sort((a, b) => a.localeCompare(b));
335
+ for (const parent of parents) {
336
+ const children = [...(parentToChildren.get(parent) ?? [])].sort((a, b) => a.localeCompare(b));
337
+ const childList = children.map((c) => `\`${c}\``).join(', ');
338
+ lines.push(`- **\`${parent}\`** is now an orphaned dependency. Downstream flags that depended on it: ${childList}.`);
339
+ }
340
+ }
341
+ return lines;
342
+ };
343
+ /**
344
+ * qfg-l8uz: substitute every `<hex maintainerId>` in a dropped-maintainer
345
+ * detail with `<id> (<email>)` so the per-flag rollup reads as a human label
346
+ * instead of an opaque hex string. No-op when the map is empty/missing or the
347
+ * ID isn't in it (team-key drops, partial member visibility) — the unsubstituted
348
+ * line is still informative.
349
+ */
350
+ const decorateMaintainerDetail = (detail, maintainerMap) => {
351
+ if (!maintainerMap)
352
+ return detail;
353
+ return detail.replaceAll(/[\da-f]{24}/g, (match) => {
354
+ const email = maintainerMap[match];
355
+ return email ? `${match} (${email})` : match;
356
+ });
357
+ };
358
+ const renderConversionNotes = (notes, maintainerMap) => {
167
359
  // `skipped-config` (own section) and `rebucketed-rollout` (own section) are
168
360
  // rendered elsewhere — everything else is grouped by category here.
169
361
  const grouped = (notes ?? []).filter((n) => n.category !== 'rebucketed-rollout' && n.category !== 'skipped-config');
@@ -182,12 +374,141 @@ const renderConversionNotes = (notes) => {
182
374
  continue;
183
375
  lines.push('', `### ${CONVERSION_NOTE_HEADINGS[category]}`, '');
184
376
  const sorted = [...forCategory].sort((a, b) => a.key.localeCompare(b.key));
377
+ const rollup = COLLAPSED_CATEGORIES[category];
378
+ if (rollup) {
379
+ lines.push(rollup(sorted.length), '', '<details><summary>Per-flag list</summary>', '');
380
+ for (const note of sorted) {
381
+ const detail = category === 'dropped-maintainer' ? decorateMaintainerDetail(note.detail, maintainerMap) : note.detail;
382
+ lines.push(`- \`${note.key}\` — ${detail}`);
383
+ }
384
+ lines.push('', '</details>');
385
+ continue;
386
+ }
387
+ if (category === 'dropped-prerequisite') {
388
+ lines.push(...renderDroppedPrerequisiteBody(sorted));
389
+ continue;
390
+ }
185
391
  for (const note of sorted) {
186
392
  lines.push(`- \`${note.key}\` — ${note.detail}`);
187
393
  }
188
394
  }
189
395
  return lines.join('\n');
190
396
  };
397
+ /**
398
+ * Maps each lossy conversion-note category to a Follow-up checklist bucket plus
399
+ * a 1-line remediation hint. Categories not listed here (or `skipped-config` /
400
+ * housekeeping categories like `dropped-maintainer`) are intentionally not
401
+ * surfaced as checklist items — they get their own dedicated report sections
402
+ * or are pure-metadata losses that don't require human follow-up.
403
+ */
404
+ const FOLLOW_UP_BUCKETS = {
405
+ 'dropped-mobile-key-now-server-only': {
406
+ bucket: 'mustFixBeforeCutover',
407
+ 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.`,
408
+ },
409
+ 'dropped-prerequisite': {
410
+ bucket: 'mustFixBeforeCutover',
411
+ hint: (key) => `\`${key}\` — restore the dropped prerequisite gate manually; Quonfig v1 has no cross-flag dependency operator.`,
412
+ },
413
+ 'individual-target-as-rule': {
414
+ bucket: 'reviewPostCutover',
415
+ hint: (key) => `\`${key}\` — individual user targets are now a leading rule; the LaunchDarkly targets pane affordance is gone.`,
416
+ },
417
+ 'rebucketed-rollout': {
418
+ bucket: 'reviewPostCutover',
419
+ hint: (key) => `\`${key}\` — percentage rollout will re-bucket users post-migration; communicate before cutover.`,
420
+ },
421
+ 'skipped-rule': {
422
+ bucket: 'mustFixBeforeCutover',
423
+ hint: (key) => `\`${key}\` — a rule clause used an unsupported operator and was skipped; rebuild the rule by hand.`,
424
+ },
425
+ 'unexportable-segment-membership': {
426
+ bucket: 'mustFixBeforeCutover',
427
+ hint: (key) => `\`${key}\` — segment shell migrated but membership is empty (LD big/synced segment); repopulate members before cutover.`,
428
+ },
429
+ };
430
+ /**
431
+ * Folds conversion notes into a followUp checklist. De-duplicates by hint
432
+ * string so a flag with two dropped prerequisites doesn't produce two
433
+ * identical checkbox lines.
434
+ */
435
+ export const deriveFollowUpFromConversionNotes = (base, notes) => {
436
+ const mustFix = new Set(base.mustFixBeforeCutover);
437
+ const review = new Set(base.reviewPostCutover);
438
+ for (const note of notes ?? []) {
439
+ const mapping = FOLLOW_UP_BUCKETS[note.category];
440
+ if (!mapping)
441
+ continue;
442
+ const line = mapping.hint(note.key);
443
+ if (mapping.bucket === 'mustFixBeforeCutover')
444
+ mustFix.add(line);
445
+ else
446
+ review.add(line);
447
+ }
448
+ return { mustFixBeforeCutover: [...mustFix], reviewPostCutover: [...review] };
449
+ };
450
+ /**
451
+ * Static, content-stable appendix describing v1 behavioral gaps between
452
+ * LaunchDarkly and Quonfig (plan §5.4 / qfg-ox7m). Documentation, not data —
453
+ * does not vary across runs. Rendered only when source === 'launchdarkly'.
454
+ */
455
+ const LAUNCHDARKLY_BEHAVIORAL_DIFFERENCES_APPENDIX = [
456
+ '## Behavioral differences post-cutover (independent of this import)',
457
+ '',
458
+ '- LaunchDarkly evaluates targets/contextTargets *before* rules. Quonfig has no',
459
+ ' individual-target primitive; user/context targets are now a leading PROP_IS_ONE_OF',
460
+ ' rule. Adding/removing individuals is now a config edit (qfg set / web UI), not a',
461
+ ' separate targeting UI pane.',
462
+ "- LaunchDarkly's 'off' toggle disappears. A flag is on iff at least one rule",
463
+ ' matches; there is no global kill-switch unless you author one as a top rule.',
464
+ '- offVariation does not exist in Quonfig. The off-state behavior of any flag',
465
+ ' that was on=false in LD has been baked into a single ALWAYS_TRUE rule serving',
466
+ ' that variation.',
467
+ '- Mobile SDK keys: LaunchDarkly distinguished mobile-key vs environment-id',
468
+ ' availability. Quonfig has one sendToClientSdk bool — your iOS/Android and',
469
+ ' browser-JS SDKs now share the same client-visibility setting.',
470
+ '- Maintainer is no longer a field on flags. `git log path/to/flag.json` is the',
471
+ ' new source of truth.',
472
+ ].join('\n');
473
+ /**
474
+ * Flagsmith-specific behavioral-differences appendix (plan §5.5). Same shape
475
+ * as the LD version above — static documentation about gaps a customer reading
476
+ * MIGRATION_REPORT.md should know about independently of their specific data.
477
+ */
478
+ const FLAGSMITH_BEHAVIORAL_DIFFERENCES_APPENDIX = [
479
+ '## Behavioral differences post-cutover (independent of this import)',
480
+ '',
481
+ '- `enabled=false` semantics differ. Flagsmith ignores `feature_state_value` when',
482
+ " `enabled=false` and the SDK serves your code's hardcoded default. Quonfig always",
483
+ ' serves the configured value. For boolean features the migrator emits `false`; for',
484
+ ' non-boolean features it emits the stored `feature_state_value` and surfaces every',
485
+ ' affected flag under "Coerced sentinel rule values" above. Review each before',
486
+ ' cutover — Flagsmith\'s "off" behavior does NOT round-trip.',
487
+ '- Flagsmith has no individual-target primitive in Quonfig v1. Identity overrides',
488
+ ' are emitted as leading `PROP_IS_ONE_OF` rules on `user.key`. Adding/removing an',
489
+ ' identity is now a config edit (qfg set / web UI), not a per-identity row.',
490
+ '- Identity traits are runtime context Flagsmith infers from the trait directory.',
491
+ ' Quonfig has no server-side trait store — your SDK callers must send every trait',
492
+ ' attribute referenced by a segment rule on the eval context. See "Identity traits',
493
+ ' referenced by segment rules" above for the exhaustive list.',
494
+ '- Multivariate bucketing salts differ between Flagsmith and Quonfig. The percentage',
495
+ ' allocation is preserved, but identity → variation assignments WILL change. See',
496
+ ' "Users will be re-bucketed" above.',
497
+ '- MODULO and PERCENTAGE_SPLIT segment-condition operators have no Quonfig equivalent.',
498
+ ' Rules using them were dropped with `skipped-rule` notes; rebuild by hand if you',
499
+ ' need the segmentation.',
500
+ '- Segment rule combinators `type=ANY` and `type=NONE` (within-rule OR / NAND) have',
501
+ ' no Quonfig equivalent — Quonfig criteria within a single rule are AND-only. Those',
502
+ ' rules were dropped with `skipped-rule` notes.',
503
+ "- `:semver`-suffixed values are mapped to Quonfig's `PROP_SEMVER_*` operators.",
504
+ ' Inclusive variants (GTE/LTE) have no semver equivalent and were dropped.',
505
+ '- Soft-deleted features in Flagsmith are functionally hard-deleted from the public',
506
+ ' API (404 on GET, absent from list responses). The migrator cannot enumerate them;',
507
+ ' any features deleted before this snapshot was taken are simply not present in the',
508
+ ' Quonfig workspace.',
509
+ '- Feature owners, project metadata, change requests, and scheduled changes are out',
510
+ ' of v1 scope. None round-trip into Quonfig.',
511
+ ].join('\n');
191
512
  const renderFollowUp = (followUp) => {
192
513
  const must = followUp.mustFixBeforeCutover.length === 0
193
514
  ? NONE
@@ -212,7 +533,11 @@ export const buildMigrationReport = (data) => {
212
533
  if (data.dryRun) {
213
534
  sections.push('> **DRY RUN** — no files were written to the workspace during this run.');
214
535
  }
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));
536
+ sections.push(`# Migration Report (from ${data.source})`, `_Reflects only the changes produced by this run. Re-running overwrites this file._`);
537
+ const beforeYouCutOver = renderBeforeYouCutOver(data.conversionNotes);
538
+ if (beforeYouCutOver !== null)
539
+ sections.push(beforeYouCutOver);
540
+ sections.push(renderCounts(data.counts), renderCleanMappings(data.cleanMappings), renderLossyMappings(data.lossyMappings), renderUnsupported(data.unsupportedFeatures), renderEnvironmentMap(data.environmentMap), renderIdentifierMap(data.identifierMap, data.maintainerMap));
216
541
  const dropped = renderDroppedOverrides(data.droppedOverrides);
217
542
  if (dropped !== null)
218
543
  sections.push(dropped);
@@ -222,16 +547,22 @@ export const buildMigrationReport = (data) => {
222
547
  const skipped = renderSkippedConfigs(data.skippedConfigs);
223
548
  if (skipped !== null)
224
549
  sections.push(skipped);
225
- const coerced = renderCoercedSentinels(data.coercedSentinels);
550
+ const coerced = renderCoercedSentinels(data.coercedSentinels, data.source);
226
551
  if (coerced !== null)
227
552
  sections.push(coerced);
228
- const rebucketed = renderRebucketedRollouts(data.conversionNotes);
553
+ const rebucketed = renderRebucketedRollouts(data.conversionNotes, data.source);
229
554
  if (rebucketed !== null)
230
555
  sections.push(rebucketed);
231
- const conversionNotes = renderConversionNotes(data.conversionNotes);
556
+ const conversionNotes = renderConversionNotes(data.conversionNotes, data.maintainerMap);
232
557
  if (conversionNotes !== null)
233
558
  sections.push(conversionNotes);
234
559
  sections.push(renderFollowUp(data.followUp));
560
+ if (data.source === 'launchdarkly') {
561
+ sections.push(LAUNCHDARKLY_BEHAVIORAL_DIFFERENCES_APPENDIX);
562
+ }
563
+ if (data.source === 'flagsmith') {
564
+ sections.push(FLAGSMITH_BEHAVIORAL_DIFFERENCES_APPENDIX);
565
+ }
235
566
  return sections.join('\n\n') + '\n';
236
567
  };
237
568
  export const migrationReportPath = (outputDir) => path.join(outputDir, 'MIGRATION_REPORT.md');