@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.
- package/README.md +50 -48
- package/dist/commands/generate.js +21 -5
- package/dist/commands/info.d.ts +1 -0
- package/dist/commands/info.js +28 -1
- package/dist/commands/migrate.d.ts +1 -1
- package/dist/commands/migrate.js +16 -5
- package/dist/commands/pull.js +2 -2
- package/dist/init/templates.js +59 -12
- package/dist/migrate/local-write.js +11 -1
- package/dist/migrate/migration-report.d.ts +16 -5
- package/dist/migrate/migration-report.js +351 -20
- package/dist/migrate/push-to-cloud.js +6 -1
- package/dist/migrate/quonfig-target/report.d.ts +45 -4
- package/dist/migrate/quonfig-target/report.js +5 -2
- package/dist/migrate/quonfig-target/ruleset.js +8 -6
- package/dist/migrate/source.d.ts +27 -0
- package/dist/migrate/sources/flagsmith/api.d.ts +115 -0
- package/dist/migrate/sources/flagsmith/api.js +402 -0
- package/dist/migrate/sources/flagsmith/translate.d.ts +72 -0
- package/dist/migrate/sources/flagsmith/translate.js +657 -0
- package/dist/migrate/sources/flagsmith/types.d.ts +282 -0
- package/dist/migrate/sources/flagsmith/types.js +18 -0
- package/dist/migrate/sources/flagsmith.d.ts +62 -0
- package/dist/migrate/sources/flagsmith.js +380 -23
- package/dist/migrate/sources/launchdarkly/api.d.ts +22 -1
- package/dist/migrate/sources/launchdarkly/api.js +23 -1
- package/dist/migrate/sources/launchdarkly/translate.js +42 -2
- package/dist/migrate/sources/launchdarkly/types.d.ts +19 -0
- package/dist/migrate/sources/launchdarkly.js +75 -4
- package/dist/util/friction-log.d.ts +1 -0
- package/dist/util/friction-log.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +158 -160
- package/package.json +2 -2
package/dist/init/templates.js
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
35
|
-
qfg
|
|
41
|
+
# 3. (Optional) Generate TypeScript types for your app.
|
|
42
|
+
qfg generate
|
|
36
43
|
|
|
37
|
-
#
|
|
38
|
-
|
|
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
|
-
|
|
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. **
|
|
148
|
-
2.
|
|
149
|
-
3.
|
|
150
|
-
4.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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._
|
|
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');
|