@quonfig/cli 0.0.65 → 0.0.67
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 +53 -51
- package/dist/commands/create.js +9 -0
- package/dist/commands/migrate.d.ts +1 -0
- package/dist/commands/migrate.js +10 -0
- package/dist/migrate/import-state.d.ts +3 -0
- package/dist/migrate/import-state.js +32 -0
- package/dist/migrate/key-rewriter.d.ts +82 -0
- package/dist/migrate/key-rewriter.js +190 -0
- package/dist/migrate/key-sanitize.d.ts +13 -0
- package/dist/migrate/key-sanitize.js +108 -0
- package/dist/migrate/local-write.d.ts +2 -0
- package/dist/migrate/local-write.js +15 -1
- package/dist/migrate/migration-report.d.ts +14 -0
- package/dist/migrate/migration-report.js +38 -0
- package/dist/migrate/push-to-cloud.d.ts +2 -0
- package/dist/migrate/push-to-cloud.js +23 -1
- package/dist/migrate/quonfig-target/ruleset.js +5 -1
- package/dist/migrate/sources/flagsmith/translate.js +10 -2
- package/dist/migrate/sources/launch/translate.d.ts +6 -4
- package/dist/migrate/sources/launch/translate.js +25 -5
- package/dist/migrate/sources/launchdarkly/translate.js +7 -4
- package/dist/util/policy-a-key.d.ts +26 -0
- package/dist/util/policy-a-key.js +42 -0
- package/dist/verify/standalone.d.ts +13 -1
- package/dist/verify/standalone.js +37 -29
- package/dist/verify/validate.js +21 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +7 -1
- package/package.json +8 -2
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-run key rewriter for `qfg migrate` (qfg-6na9.3).
|
|
3
|
+
*
|
|
4
|
+
* `sanitizePolicyAKey` (key-sanitize.ts) fixes ONE key in isolation. This module
|
|
5
|
+
* turns that into a workspace-consistent map so that:
|
|
6
|
+
*
|
|
7
|
+
* 1. two distinct source keys that sanitize to the same key (`foo bar` and
|
|
8
|
+
* `foo-bar` -> `foo-bar`), or that collide case-insensitively (`Foo`/`foo`,
|
|
9
|
+
* which the FS-safety floor hard-rejects), are DISAMBIGUATED deterministically
|
|
10
|
+
* (`-2`, `-3`, ...) instead of silently clobbering one another; and
|
|
11
|
+
* 2. a key and every by-key REFERENCE to it (a flag's `IN_SEG`/`NOT_IN_SEG`
|
|
12
|
+
* rule pointing at a segment key) resolve to the SAME final key — so a
|
|
13
|
+
* renamed segment never leaves a dangling targeting reference.
|
|
14
|
+
*
|
|
15
|
+
* Usage: `resetKeyRewriter()` then `planKeyRewrites(allSourceKeys)` BEFORE
|
|
16
|
+
* translating any change, then `resolveKey(sourceKey)` at every key-definition
|
|
17
|
+
* and reference site. `getKeyRewrites()` drives MIGRATION_REPORT.md + the
|
|
18
|
+
* machine-readable key map.
|
|
19
|
+
*
|
|
20
|
+
* `resolveKey` on a key that was never planned falls back to a pure
|
|
21
|
+
* `sanitizePolicyAKey` (NO disambiguation, NO state mutation) so that direct
|
|
22
|
+
* unit calls to a source's path builders stay referentially transparent.
|
|
23
|
+
*/
|
|
24
|
+
import { sanitizePolicyAKey } from './key-sanitize.js';
|
|
25
|
+
const state = { bySource: new Map(), persisted: new Map(), takenLower: new Map() };
|
|
26
|
+
export function resetKeyRewriter() {
|
|
27
|
+
state.bySource = new Map();
|
|
28
|
+
state.persisted = new Map();
|
|
29
|
+
state.takenLower = new Map();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Seed the rewriter with the complete source->final map persisted by previous
|
|
33
|
+
* runs. Every persisted final is immediately marked taken (case-insensitively)
|
|
34
|
+
* so keys planned later this run can never claim one. Call after
|
|
35
|
+
* `resetKeyRewriter()` and before `planKeyRewrites()` — `planKeyRewritesForChanges`
|
|
36
|
+
* does this ordering for you.
|
|
37
|
+
*/
|
|
38
|
+
export function seedPersistedKeyPlan(keys) {
|
|
39
|
+
for (const [sourceKey, final] of Object.entries(keys)) {
|
|
40
|
+
state.persisted.set(sourceKey, final);
|
|
41
|
+
state.takenLower.set(final.toLowerCase(), sourceKey);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Find the first case-insensitively-free variant of `base` (base, base-2, ...). */
|
|
45
|
+
function disambiguate(base) {
|
|
46
|
+
if (!state.takenLower.has(base.toLowerCase()))
|
|
47
|
+
return base;
|
|
48
|
+
for (let n = 2;; n++) {
|
|
49
|
+
const suffix = `-${n}`;
|
|
50
|
+
const room = 200 - suffix.length;
|
|
51
|
+
const candidate = (base.length > room ? base.slice(0, room) : base) + suffix;
|
|
52
|
+
if (!state.takenLower.has(candidate.toLowerCase()))
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Disambiguate `base`, record the mapping, and mark the final taken. */
|
|
57
|
+
function assignFinal(sourceKey, base, sanitizeReasons) {
|
|
58
|
+
const final = disambiguate(base);
|
|
59
|
+
const reasons = [...sanitizeReasons];
|
|
60
|
+
if (final !== base)
|
|
61
|
+
reasons.push('appended a numeric suffix to keep the key unique');
|
|
62
|
+
state.bySource.set(sourceKey, { final, reasons, source: sourceKey });
|
|
63
|
+
state.takenLower.set(final.toLowerCase(), sourceKey);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Pre-pass: compute the final key for every source key. Three passes, each
|
|
67
|
+
* sorted so every assignment is deterministic and independent of the order
|
|
68
|
+
* changes were fetched in:
|
|
69
|
+
*
|
|
70
|
+
* 0. Keys mapped by a PREVIOUS run keep their persisted final verbatim (see
|
|
71
|
+
* `seedPersistedKeyPlan`) — full and delta runs must resolve identically.
|
|
72
|
+
* 1. Keys that are ALREADY fully valid (sanitize is an identity: they pass
|
|
73
|
+
* Policy A and the FS-floor) claim their own names FIRST and are never
|
|
74
|
+
* renamed to make room for sanitized junk — customer code calling
|
|
75
|
+
* get("my-flag") must keep resolving to the same flag even when a source
|
|
76
|
+
* key like "my flag" sanitizes to the same name. If two VALID keys
|
|
77
|
+
* collide case-insensitively ("Foo"/"foo" — a genuine source conflict the
|
|
78
|
+
* FS-floor hard-rejects), the lexicographically-first one keeps its name
|
|
79
|
+
* and the other is suffixed.
|
|
80
|
+
* 2. Keys that needed sanitizing then disambiguate AROUND the valid ones
|
|
81
|
+
* (-2, -3, ...).
|
|
82
|
+
*/
|
|
83
|
+
export function planKeyRewrites(sourceKeys) {
|
|
84
|
+
const fresh = [...new Set(sourceKeys)].sort().filter((k) => !state.bySource.has(k));
|
|
85
|
+
// Pass 0: previously-mapped keys resolve to their persisted final, always.
|
|
86
|
+
const unpersisted = [];
|
|
87
|
+
for (const sourceKey of fresh) {
|
|
88
|
+
const persistedFinal = state.persisted.get(sourceKey);
|
|
89
|
+
if (persistedFinal === undefined) {
|
|
90
|
+
unpersisted.push(sourceKey);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const sanitized = sanitizePolicyAKey(sourceKey);
|
|
94
|
+
const reasons = persistedFinal === sanitized.key
|
|
95
|
+
? [...sanitized.reasons]
|
|
96
|
+
: [...sanitized.reasons, 'kept the key assigned by a previous import run'];
|
|
97
|
+
state.bySource.set(sourceKey, { final: persistedFinal, reasons, source: sourceKey });
|
|
98
|
+
state.takenLower.set(persistedFinal.toLowerCase(), sourceKey);
|
|
99
|
+
}
|
|
100
|
+
// Pass 1: already-valid keys claim their own names.
|
|
101
|
+
const needsSanitizing = [];
|
|
102
|
+
for (const sourceKey of unpersisted) {
|
|
103
|
+
const sanitized = sanitizePolicyAKey(sourceKey);
|
|
104
|
+
if (sanitized.changed) {
|
|
105
|
+
needsSanitizing.push({ sanitized, sourceKey });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
assignFinal(sourceKey, sourceKey, []);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Pass 2: sanitized keys disambiguate around them.
|
|
112
|
+
for (const { sanitized, sourceKey } of needsSanitizing) {
|
|
113
|
+
assignFinal(sourceKey, sanitized.key, sanitized.reasons);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The final key for `sourceKey`. Uses the planned map when present, then the
|
|
118
|
+
* PERSISTED map (a delta run's flag can reference a segment that was migrated
|
|
119
|
+
* in the full run but is absent from the delta's change set), else a pure
|
|
120
|
+
* sanitize (no disambiguation, no mutation) as a safe fallback.
|
|
121
|
+
*/
|
|
122
|
+
export function resolveKey(sourceKey) {
|
|
123
|
+
const planned = state.bySource.get(sourceKey);
|
|
124
|
+
if (planned)
|
|
125
|
+
return planned.final;
|
|
126
|
+
const persisted = state.persisted.get(sourceKey);
|
|
127
|
+
if (persisted !== undefined)
|
|
128
|
+
return persisted;
|
|
129
|
+
return sanitizePolicyAKey(sourceKey).key;
|
|
130
|
+
}
|
|
131
|
+
/** Every key that was actually rewritten (conforming keys are omitted). */
|
|
132
|
+
export function getKeyRewrites() {
|
|
133
|
+
return [...state.bySource.values()].filter((r) => r.final !== r.source);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The COMPLETE source->final map to persist to `.qf/key-plan.json`: every key
|
|
137
|
+
* planned this run (unchanged ones included) merged over everything persisted
|
|
138
|
+
* by previous runs, sorted by source key for stable on-disk diffs.
|
|
139
|
+
*/
|
|
140
|
+
export function getFullKeyPlan() {
|
|
141
|
+
const merged = new Map(state.persisted);
|
|
142
|
+
for (const [source, rewrite] of state.bySource)
|
|
143
|
+
merged.set(source, rewrite.final);
|
|
144
|
+
const out = {};
|
|
145
|
+
for (const source of [...merged.keys()].sort())
|
|
146
|
+
out[source] = merged.get(source);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Run-level pre-pass: reset the rewriter, seed the persisted plan from any
|
|
151
|
+
* previous run, and plan every source key BEFORE any change is translated, so
|
|
152
|
+
* both key-definition and by-key reference sites resolve against the same
|
|
153
|
+
* fully-disambiguated map. Structurally typed on `{key?}` to stay decoupled
|
|
154
|
+
* from LegacyChange.
|
|
155
|
+
*/
|
|
156
|
+
export function planKeyRewritesForChanges(changes, persistedKeys) {
|
|
157
|
+
resetKeyRewriter();
|
|
158
|
+
if (persistedKeys)
|
|
159
|
+
seedPersistedKeyPlan(persistedKeys);
|
|
160
|
+
planKeyRewrites(changes.flatMap((c) => (typeof c.key === 'string' ? [c.key] : [])));
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* `--strict-keys` escape hatch: refuse to migrate if any source key would need
|
|
164
|
+
* rewriting, so a customer who requires byte-identical keys can clean up the
|
|
165
|
+
* source first instead of accepting the (reported) rewrites.
|
|
166
|
+
*/
|
|
167
|
+
export class StrictKeysError extends Error {
|
|
168
|
+
rewrites;
|
|
169
|
+
constructor(rewrites) {
|
|
170
|
+
super(`--strict-keys: ${rewrites.length} source key(s) do not conform to Policy A and would be rewritten:\n` +
|
|
171
|
+
rewrites.map((r) => ` "${r.source}" -> "${r.final}" (${r.reasons.join('; ')})`).join('\n') +
|
|
172
|
+
`\nRename the key(s) in the source system and re-run, or drop --strict-keys to accept the rewrites.`);
|
|
173
|
+
this.rewrites = rewrites;
|
|
174
|
+
this.name = 'StrictKeysError';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Plan the rewrites for a run and, when `strict`, throw `StrictKeysError` if any
|
|
179
|
+
* key would be rewritten. Call once at the top of each migrate orchestrator
|
|
180
|
+
* before translating. `persistedKeys` (from `readKeyPlan`) makes previously
|
|
181
|
+
* mapped keys resolve exactly as they did on the run that mapped them.
|
|
182
|
+
*/
|
|
183
|
+
export function preflightKeyRewrites(changes, opts) {
|
|
184
|
+
planKeyRewritesForChanges(changes, opts?.persistedKeys);
|
|
185
|
+
if (opts?.strict) {
|
|
186
|
+
const rewrites = getKeyRewrites();
|
|
187
|
+
if (rewrites.length > 0)
|
|
188
|
+
throw new StrictKeysError(rewrites);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
declare const POLICY_A_KEY_RE: RegExp;
|
|
2
|
+
export interface KeySanitizeResult {
|
|
3
|
+
/** True when `key` differs from `raw`. */
|
|
4
|
+
changed: boolean;
|
|
5
|
+
/** The Policy-A-valid key (== `raw` when nothing needed changing). */
|
|
6
|
+
key: string;
|
|
7
|
+
/** Human-readable list of the transforms that fired (empty when unchanged). */
|
|
8
|
+
reasons: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare function sanitizePolicyAKey(raw: string): KeySanitizeResult;
|
|
11
|
+
/** True when a key already satisfies Policy A (no sanitizing needed). */
|
|
12
|
+
export declare function isPolicyAConformant(key: string): boolean;
|
|
13
|
+
export { POLICY_A_KEY_RE };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic single-key Policy A sanitizer for `qfg migrate` (qfg-6na9.3).
|
|
3
|
+
*
|
|
4
|
+
* Migrated keys enter a workspace via `cloneAndStackPush` -> the app-gitea
|
|
5
|
+
* pre-receive `qfg-verify` hook, which hard-rejects FS-floor violations
|
|
6
|
+
* (qfg-6na9.4) and — after the warn->error soak — will hard-reject Policy A
|
|
7
|
+
* charset violations too (qfg-6na9.6). To keep migrations from (a) aborting on
|
|
8
|
+
* a floor violation or (b) seeding a permanently non-conforming key that
|
|
9
|
+
* freezes the customer once .6 flips, we rewrite every imported key so it 100%
|
|
10
|
+
* conforms to Policy A (project/plans/26-06-tighter-naming.md).
|
|
11
|
+
*
|
|
12
|
+
* This function is a PURE, deterministic single-key transform. It preserves as
|
|
13
|
+
* much of the original as possible (case included — Policy A allows mixed case)
|
|
14
|
+
* and is a strict no-op for already-conforming keys, so LaunchDarkly imports
|
|
15
|
+
* (LD keys are already `[A-Za-z0-9._-]`) produce zero rewrites.
|
|
16
|
+
*
|
|
17
|
+
* Cross-key concerns — collisions (two source keys sanitizing to the same key)
|
|
18
|
+
* and case-insensitive uniqueness — are layered on top by the per-run key
|
|
19
|
+
* rewriter (`key-rewriter.ts`), which also drives the report. Keep those
|
|
20
|
+
* concerns OUT of here so this stays trivially testable and referentially
|
|
21
|
+
* transparent (both the key-definition side and the reference side can call it
|
|
22
|
+
* independently and get the same answer).
|
|
23
|
+
*/
|
|
24
|
+
/* eslint-disable unicorn/better-regex -- keep the explicit [A-Za-z0-9._-] charset
|
|
25
|
+
greppable and byte-for-byte aligned with app-quonfig's PolicyAKeySchema and the
|
|
26
|
+
plan (project/plans/26-06-tighter-naming.md), rather than the equivalent [\w.-]. */
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
28
|
+
const RESERVED_DEVICE_RE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
29
|
+
const POLICY_A_KEY_RE = /^[A-Za-z0-9._-]+$/;
|
|
30
|
+
const MAX_KEY_LEN = 200;
|
|
31
|
+
/** First 8 hex chars of sha1(raw) — a stable, collision-resistant suffix. */
|
|
32
|
+
function shortHash(raw) {
|
|
33
|
+
return createHash('sha1').update(raw).digest('hex').slice(0, 8);
|
|
34
|
+
}
|
|
35
|
+
export function sanitizePolicyAKey(raw) {
|
|
36
|
+
const reasons = [];
|
|
37
|
+
let k = raw;
|
|
38
|
+
// 0. Trim leading/trailing whitespace FIRST, so an edge space is dropped (a
|
|
39
|
+
// trailing space is an FS-floor violation — stripped by Windows) rather
|
|
40
|
+
// than surviving as a cosmetic dash via the charset replacement below.
|
|
41
|
+
const wsTrimmed = k.replaceAll(/^\s+|\s+$/g, '');
|
|
42
|
+
if (wsTrimmed !== k) {
|
|
43
|
+
k = wsTrimmed;
|
|
44
|
+
reasons.push('trimmed leading/trailing whitespace');
|
|
45
|
+
}
|
|
46
|
+
// 1. Path separators -> dot. Preserves the dotted-hierarchy intent (a `/` or
|
|
47
|
+
// `\` in a source key becomes a namespace dot, matching the historical
|
|
48
|
+
// normalizeKey) rather than a lossy dash.
|
|
49
|
+
if (/[/\\]/.test(k)) {
|
|
50
|
+
k = k.replaceAll(/[/\\]+/g, '.');
|
|
51
|
+
reasons.push('replaced path separators (/ or \\) with "."');
|
|
52
|
+
}
|
|
53
|
+
// 2. Any remaining char outside the Policy A charset -> single dash.
|
|
54
|
+
if (/[^A-Za-z0-9._-]/.test(k)) {
|
|
55
|
+
k = k.replaceAll(/[^A-Za-z0-9._-]+/g, '-');
|
|
56
|
+
reasons.push('replaced disallowed characters with "-"');
|
|
57
|
+
}
|
|
58
|
+
// 3. Trim leading/trailing DOTS only. A leading dot is silently skipped by
|
|
59
|
+
// the loader/verify; a trailing dot is stripped by Windows — both are
|
|
60
|
+
// floor violations. Leading/trailing DASHES are fully legal under Policy A
|
|
61
|
+
// and the floor, so they are deliberately NOT trimmed: this function must
|
|
62
|
+
// be an identity on every already-valid key (otherwise --strict-keys
|
|
63
|
+
// aborts on keys the platform accepts, and a valid key gets "rewritten").
|
|
64
|
+
const trimmed = k.replaceAll(/^\.+/g, '').replaceAll(/\.+$/g, '');
|
|
65
|
+
if (trimmed !== k) {
|
|
66
|
+
k = trimmed;
|
|
67
|
+
reasons.push('trimmed leading/trailing dots');
|
|
68
|
+
}
|
|
69
|
+
// 4. Windows reserved device name on the FIRST dot-segment (the segment the
|
|
70
|
+
// floor checks) -> suffix "_" so `con` -> `con_`, `nul.foo` -> `nul_.foo`.
|
|
71
|
+
const segs = k.split('.');
|
|
72
|
+
if (segs.length > 0 && RESERVED_DEVICE_RE.test(segs[0])) {
|
|
73
|
+
segs[0] = `${segs[0]}_`;
|
|
74
|
+
k = segs.join('.');
|
|
75
|
+
reasons.push('escaped Windows reserved device name');
|
|
76
|
+
}
|
|
77
|
+
// 5. The reserved whole-key "new".
|
|
78
|
+
if (k === 'new') {
|
|
79
|
+
k = 'new-key';
|
|
80
|
+
reasons.push('escaped reserved key "new"');
|
|
81
|
+
}
|
|
82
|
+
// 6. Empty — or reduced to pure separator junk — after sanitizing (e.g. the
|
|
83
|
+
// source key was all disallowed chars, so every char became a dash). The
|
|
84
|
+
// `k !== raw` guard keeps this an identity on ALREADY-valid separator-only
|
|
85
|
+
// keys like "-" (legal under Policy A); only keys we transformed into
|
|
86
|
+
// meaningless separators get the stable placeholder, so distinct junk
|
|
87
|
+
// source keys ("!!!" vs "???") stay distinguishable.
|
|
88
|
+
if (k.length === 0) {
|
|
89
|
+
k = `key-${shortHash(raw)}`;
|
|
90
|
+
reasons.push('key was empty after sanitizing');
|
|
91
|
+
}
|
|
92
|
+
else if (k !== raw && !/[A-Za-z0-9_]/.test(k)) {
|
|
93
|
+
k = `key-${shortHash(raw)}`;
|
|
94
|
+
reasons.push('key had no letters, numbers, or underscores after sanitizing');
|
|
95
|
+
}
|
|
96
|
+
// 7. Length cap 200 (ASCII-only == byte length). Stable hash suffix keeps two
|
|
97
|
+
// long keys sharing a 200-char prefix from colliding.
|
|
98
|
+
if (k.length > MAX_KEY_LEN) {
|
|
99
|
+
k = `${k.slice(0, MAX_KEY_LEN - 9)}-${shortHash(raw)}`;
|
|
100
|
+
reasons.push(`truncated to ${MAX_KEY_LEN} characters`);
|
|
101
|
+
}
|
|
102
|
+
return { changed: k !== raw, key: k, reasons };
|
|
103
|
+
}
|
|
104
|
+
/** True when a key already satisfies Policy A (no sanitizing needed). */
|
|
105
|
+
export function isPolicyAConformant(key) {
|
|
106
|
+
return !sanitizePolicyAKey(key).changed;
|
|
107
|
+
}
|
|
108
|
+
export { POLICY_A_KEY_RE };
|
|
@@ -24,6 +24,8 @@ export interface ApplyLocalMigrationOptions {
|
|
|
24
24
|
*/
|
|
25
25
|
reportData: MigrationReportData;
|
|
26
26
|
source: MigrationSource;
|
|
27
|
+
/** qfg-6na9.3: `--strict-keys` — refuse to migrate if any key would be rewritten. */
|
|
28
|
+
strictKeys?: boolean;
|
|
27
29
|
}
|
|
28
30
|
export interface ApplyLocalMigrationResult {
|
|
29
31
|
/** `'initialized'` if we had to `git init` the target, `'reused'` if it was already a repo. */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
3
|
+
import { readKeyPlan, removeQfFromGitignore, writeImportState, writeKeyPlan } from './import-state.js';
|
|
4
|
+
import { getFullKeyPlan, getKeyRewrites, preflightKeyRewrites } from './key-rewriter.js';
|
|
4
5
|
import { deriveFollowUpFromConversionNotes, writeMigrationReport } from './migration-report.js';
|
|
5
6
|
import { detectDuplicateKeys } from './sources/launch/translate.js';
|
|
6
7
|
import { MIGRATOR_IDENTITY, stackCommits } from '../util/clone-and-stack-push.js';
|
|
@@ -181,6 +182,15 @@ export const buildMigrationCommitMessage = (source, counts) => {
|
|
|
181
182
|
export const applyLocalMigration = async (opts) => {
|
|
182
183
|
const branch = opts.branch ?? 'main';
|
|
183
184
|
const author = opts.author ?? MIGRATOR_IDENTITY;
|
|
185
|
+
// qfg-6na9.3: plan Policy A key rewrites over ALL changes up front (run-level,
|
|
186
|
+
// not per-commit) so key-def and reference sites resolve identically, then
|
|
187
|
+
// enforce --strict-keys before writing anything. Mappings persisted by a
|
|
188
|
+
// previous run (.qf/key-plan.json) are authoritative so a delta run cannot
|
|
189
|
+
// replan a subset and land on different finals.
|
|
190
|
+
preflightKeyRewrites(opts.changes, {
|
|
191
|
+
persistedKeys: readKeyPlan(opts.localDir) ?? undefined,
|
|
192
|
+
strict: opts.strictKeys,
|
|
193
|
+
});
|
|
184
194
|
if (opts.fullHistory && !opts.source.getCommitMeta) {
|
|
185
195
|
throw new Error(`--full-summary requires the source to provide per-change author + date + summary, but \`${opts.source.name}\` does not. ` +
|
|
186
196
|
`Drop --full-summary or use a source (e.g. launch) that supports it.`);
|
|
@@ -195,6 +205,7 @@ export const applyLocalMigration = async (opts) => {
|
|
|
195
205
|
const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(opts.localDir, opts.changes, opts.source);
|
|
196
206
|
removeQfFromGitignore(opts.localDir);
|
|
197
207
|
writeImportState(opts.localDir, opts.importState);
|
|
208
|
+
writeKeyPlan(opts.localDir, getFullKeyPlan());
|
|
198
209
|
const droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
|
|
199
210
|
const skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
|
|
200
211
|
const coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
|
|
@@ -207,6 +218,7 @@ export const applyLocalMigration = async (opts) => {
|
|
|
207
218
|
const reportData = {
|
|
208
219
|
...opts.reportData,
|
|
209
220
|
counts,
|
|
221
|
+
keyRewrites: getKeyRewrites(),
|
|
210
222
|
...(environmentMap && environmentMap.length > 0 ? { environmentMap } : {}),
|
|
211
223
|
followUp: deriveFollowUpFromConversionNotes(opts.reportData.followUp, conversionNotes ?? undefined),
|
|
212
224
|
...(coercedSentinels ? { coercedSentinels } : {}),
|
|
@@ -341,11 +353,13 @@ export const buildAuditFinalCommit = (opts) => ({
|
|
|
341
353
|
const duplicateResolutions = resolutionEntries.length > 0 ? { entries: resolutionEntries, total: resolutionEntries.length } : null;
|
|
342
354
|
removeQfFromGitignore(dir);
|
|
343
355
|
writeImportState(dir, opts.importState);
|
|
356
|
+
writeKeyPlan(dir, getFullKeyPlan());
|
|
344
357
|
const skippedTotal = (skippedConfigs?.total ?? 0) + resolutionEntries.reduce((sum, r) => sum + r.deleted.length, 0);
|
|
345
358
|
const counts = buildMigrationCounts(livePaths, opts.environments.length, skippedTotal);
|
|
346
359
|
const reportData = {
|
|
347
360
|
...opts.reportData,
|
|
348
361
|
counts,
|
|
362
|
+
keyRewrites: getKeyRewrites(),
|
|
349
363
|
...(environmentMap && environmentMap.length > 0 ? { environmentMap } : {}),
|
|
350
364
|
followUp: deriveFollowUpFromConversionNotes(opts.reportData.followUp, conversionNotes ?? undefined),
|
|
351
365
|
...(coercedSentinels ? { coercedSentinels } : {}),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IdentifierMap } from './identifier-map.js';
|
|
2
|
+
import type { KeyRewrite } from './key-rewriter.js';
|
|
2
3
|
import type { ConversionNote } from './quonfig-target/report.js';
|
|
3
4
|
import type { CoercedSentinelSummary, DroppedOverrideSummary, DuplicateResolutionSummary, EnvironmentMapEntry, SkippedConfigSummary } from './source.js';
|
|
4
5
|
export type { EnvironmentMapEntry } from './source.js';
|
|
@@ -61,6 +62,13 @@ export interface MigrationReportData {
|
|
|
61
62
|
environmentMap: EnvironmentMapEntry[];
|
|
62
63
|
followUp: FollowUpChecklist;
|
|
63
64
|
identifierMap: IdentifierMap;
|
|
65
|
+
/**
|
|
66
|
+
* qfg-6na9.3: source keys the migrator rewrote so the workspace is 100%
|
|
67
|
+
* Policy-A conformant (bad charset, FS-floor, or collision). The renamed key
|
|
68
|
+
* is what the customer's SDK code must now look up. Null/empty when every
|
|
69
|
+
* source key already conformed (e.g. every LaunchDarkly import).
|
|
70
|
+
*/
|
|
71
|
+
keyRewrites?: KeyRewrite[] | null;
|
|
64
72
|
lossyMappings: LossyMapping[];
|
|
65
73
|
/**
|
|
66
74
|
* qfg-l8uz: source maintainerId → email pairs. Rendered as a sub-table on
|
|
@@ -97,4 +105,10 @@ export declare const buildMigrationReport: (data: MigrationReportData) => string
|
|
|
97
105
|
* `bare-path-diff.ts` excludes it — no server change needed.
|
|
98
106
|
*/
|
|
99
107
|
export declare const migrationReportPath: (outputDir: string) => string;
|
|
108
|
+
/**
|
|
109
|
+
* qfg-6na9.3: machine-readable `source -> quonfig` key map, next to the report
|
|
110
|
+
* under `.qf/` (excluded from the push, like MIGRATION_REPORT.md). Lets a
|
|
111
|
+
* customer script the SDK-lookup updates for renamed keys.
|
|
112
|
+
*/
|
|
113
|
+
export declare const keyMapPath: (outputDir: string) => string;
|
|
100
114
|
export declare const writeMigrationReport: (outputDir: string, data: MigrationReportData) => string;
|
|
@@ -110,6 +110,23 @@ const renderDuplicateResolutions = (resolved) => {
|
|
|
110
110
|
}
|
|
111
111
|
return lines.join('\n');
|
|
112
112
|
};
|
|
113
|
+
const renderKeyRewrites = (rewrites) => {
|
|
114
|
+
if (!rewrites || rewrites.length === 0)
|
|
115
|
+
return null;
|
|
116
|
+
const lines = [
|
|
117
|
+
'## Rewritten keys (ACTION REQUIRED)',
|
|
118
|
+
'',
|
|
119
|
+
`The migrator rewrote **${rewrites.length}** source key(s) that Quonfig does not allow verbatim (Policy A: letters, numbers, \`.\`, \`-\`, \`_\`; 200 chars max; no leading dot) or that collided with another key. **The Quonfig key is what your SDK code must now look up** — update any \`get("...")\` / \`variation("...")\` call that used the old name. A machine-readable map is also written to \`.qf/key-map.json\`.`,
|
|
120
|
+
'',
|
|
121
|
+
'| Source key | Quonfig key | Why |',
|
|
122
|
+
'| --- | --- | --- |',
|
|
123
|
+
];
|
|
124
|
+
const sorted = [...rewrites].sort((a, b) => a.source.localeCompare(b.source));
|
|
125
|
+
for (const r of sorted) {
|
|
126
|
+
lines.push(`| \`${r.source}\` | \`${r.final}\` | ${r.reasons.join('; ')} |`);
|
|
127
|
+
}
|
|
128
|
+
return lines.join('\n');
|
|
129
|
+
};
|
|
113
130
|
const FLAGSMITH_ENV_BUCKET_LABEL = {
|
|
114
131
|
'_all-envs': 'Override rows (all environments)',
|
|
115
132
|
'_cross-env': 'Cross-environment value-type divergence (no single env owns this)',
|
|
@@ -541,6 +558,9 @@ export const buildMigrationReport = (data) => {
|
|
|
541
558
|
const dropped = renderDroppedOverrides(data.droppedOverrides);
|
|
542
559
|
if (dropped !== null)
|
|
543
560
|
sections.push(dropped);
|
|
561
|
+
const keyRewrites = renderKeyRewrites(data.keyRewrites);
|
|
562
|
+
if (keyRewrites !== null)
|
|
563
|
+
sections.push(keyRewrites);
|
|
544
564
|
const resolved = renderDuplicateResolutions(data.duplicateResolutions);
|
|
545
565
|
if (resolved !== null)
|
|
546
566
|
sections.push(resolved);
|
|
@@ -577,9 +597,27 @@ export const buildMigrationReport = (data) => {
|
|
|
577
597
|
* `bare-path-diff.ts` excludes it — no server change needed.
|
|
578
598
|
*/
|
|
579
599
|
export const migrationReportPath = (outputDir) => path.join(outputDir, '.qf', 'MIGRATION_REPORT.md');
|
|
600
|
+
/**
|
|
601
|
+
* qfg-6na9.3: machine-readable `source -> quonfig` key map, next to the report
|
|
602
|
+
* under `.qf/` (excluded from the push, like MIGRATION_REPORT.md). Lets a
|
|
603
|
+
* customer script the SDK-lookup updates for renamed keys.
|
|
604
|
+
*/
|
|
605
|
+
export const keyMapPath = (outputDir) => path.join(outputDir, '.qf', 'key-map.json');
|
|
580
606
|
export const writeMigrationReport = (outputDir, data) => {
|
|
581
607
|
const filePath = migrationReportPath(outputDir);
|
|
582
608
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
583
609
|
fs.writeFileSync(filePath, buildMigrationReport(data), 'utf8');
|
|
610
|
+
if (data.keyRewrites && data.keyRewrites.length > 0) {
|
|
611
|
+
fs.writeFileSync(keyMapPath(outputDir), JSON.stringify({
|
|
612
|
+
rewrites: data.keyRewrites.map((r) => ({ final: r.final, reasons: r.reasons, source: r.source })),
|
|
613
|
+
source: data.source,
|
|
614
|
+
}, null, 2) + '\n', 'utf8');
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
// key-map.json describes ONE run's rewrites. A later run with zero
|
|
618
|
+
// rewrites must not leave a stale map from a previous run lying around
|
|
619
|
+
// implying those keys were rewritten by this run.
|
|
620
|
+
fs.rmSync(keyMapPath(outputDir), { force: true });
|
|
621
|
+
}
|
|
584
622
|
return filePath;
|
|
585
623
|
};
|
|
@@ -41,6 +41,8 @@ export interface PushMigrationToCloudOptions {
|
|
|
41
41
|
*/
|
|
42
42
|
reportData: MigrationReportData;
|
|
43
43
|
source: MigrationSource;
|
|
44
|
+
/** qfg-6na9.3: `--strict-keys` — refuse to migrate if any key would be rewritten. */
|
|
45
|
+
strictKeys?: boolean;
|
|
44
46
|
}
|
|
45
47
|
export interface PushMigrationToCloudResult extends CloneAndStackPushResult {
|
|
46
48
|
coercedSentinels: CoercedSentinelSummary | null;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
3
|
+
import { readKeyPlan, removeQfFromGitignore, writeImportState, writeKeyPlan } from './import-state.js';
|
|
4
4
|
import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles, } from './local-write.js';
|
|
5
|
+
import { getFullKeyPlan, getKeyRewrites, preflightKeyRewrites } from './key-rewriter.js';
|
|
5
6
|
import { deriveFollowUpFromConversionNotes, writeMigrationReport } from './migration-report.js';
|
|
6
7
|
import { cloneAndStackPush, } from '../util/clone-and-stack-push.js';
|
|
7
8
|
import { formatResult, validateWorkspace } from '../verify/validate.js';
|
|
@@ -55,6 +56,17 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
55
56
|
throw new Error(`--full-summary requires the source to provide per-change author + date + summary, but \`${opts.source.name}\` does not. ` +
|
|
56
57
|
`Drop --full-summary or use a source (e.g. launch) that supports it.`);
|
|
57
58
|
}
|
|
59
|
+
// qfg-6na9.3: plan Policy A key rewrites over ALL changes up front (run-level,
|
|
60
|
+
// not per-commit) so key-def and reference sites resolve identically, then
|
|
61
|
+
// enforce --strict-keys before any clone/commit/push. Mappings persisted by a
|
|
62
|
+
// previous run (.qf/key-plan.json in the reused clone) are authoritative so a
|
|
63
|
+
// delta run cannot replan a subset and land on different finals. (When the
|
|
64
|
+
// clone is fresh, the plan only comes down WITH the clone — the collapsed
|
|
65
|
+
// commit's apply() re-plans below once it can read the cloned .qf/.)
|
|
66
|
+
preflightKeyRewrites(opts.changes, {
|
|
67
|
+
persistedKeys: readKeyPlan(opts.localDir) ?? undefined,
|
|
68
|
+
strict: opts.strictKeys,
|
|
69
|
+
});
|
|
58
70
|
const acc = {
|
|
59
71
|
coercedSentinels: null,
|
|
60
72
|
droppedOverrides: null,
|
|
@@ -85,10 +97,19 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
85
97
|
{
|
|
86
98
|
message: () => computedCommitMessage,
|
|
87
99
|
async apply(dir) {
|
|
100
|
+
// Re-plan now that the clone exists: a FRESH clone of a previously
|
|
101
|
+
// migrated workspace delivers .qf/key-plan.json only at this point
|
|
102
|
+
// (the pre-clone preflight above couldn't see it), and the
|
|
103
|
+
// persisted mappings must win before any file is written.
|
|
104
|
+
preflightKeyRewrites(opts.changes, {
|
|
105
|
+
persistedKeys: readKeyPlan(dir) ?? undefined,
|
|
106
|
+
strict: opts.strictKeys,
|
|
107
|
+
});
|
|
88
108
|
mergeEnvsIfPresent(dir);
|
|
89
109
|
const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(dir, opts.changes, opts.source);
|
|
90
110
|
removeQfFromGitignore(dir);
|
|
91
111
|
writeImportState(dir, opts.importState);
|
|
112
|
+
writeKeyPlan(dir, getFullKeyPlan());
|
|
92
113
|
acc.droppedOverrides = opts.source.getDroppedOverrides?.() ?? null;
|
|
93
114
|
acc.skippedConfigs = opts.source.getSkippedConfigs?.() ?? null;
|
|
94
115
|
acc.coercedSentinels = opts.source.getCoercedSentinels?.() ?? null;
|
|
@@ -104,6 +125,7 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
104
125
|
const reportData = {
|
|
105
126
|
...opts.reportData,
|
|
106
127
|
counts,
|
|
128
|
+
keyRewrites: getKeyRewrites(),
|
|
107
129
|
...(environmentMap && environmentMap.length > 0 ? { environmentMap } : {}),
|
|
108
130
|
followUp: deriveFollowUpFromConversionNotes(opts.reportData.followUp, conversionNotes ?? undefined),
|
|
109
131
|
...(acc.coercedSentinels ? { coercedSentinels: acc.coercedSentinels } : {}),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* verbs do the LaunchDarkly collapse; a second provider with a different
|
|
9
9
|
* topology composes them differently.
|
|
10
10
|
*/
|
|
11
|
+
import { resolveKey } from '../key-rewriter.js';
|
|
11
12
|
import { mapLaunchDarklyOperator } from './operators.js';
|
|
12
13
|
import { toQuonfigValue } from './values.js';
|
|
13
14
|
const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
@@ -48,7 +49,10 @@ function valueToMatchFor(operator, clause, ctx) {
|
|
|
48
49
|
if (clause.values.length > 1) {
|
|
49
50
|
ctx.report.add('skipped-rule', ctx.sourceKey, `segmentMatch clause listed ${clause.values.length} segments; Quonfig IN_SEG matches one — only "${String(clause.values[0])}" kept, the rest dropped`);
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
// qfg-6na9.3: this value is a referenced segment KEY — resolve it through
|
|
53
|
+
// the per-run rewriter so a sanitized/disambiguated segment key stays in
|
|
54
|
+
// sync with the IN_SEG rules pointing at it (no dangling targeting).
|
|
55
|
+
return { type: 'string', value: resolveKey(String(clause.values[0] ?? '')) };
|
|
52
56
|
}
|
|
53
57
|
if (operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') {
|
|
54
58
|
if (clause.values.length > 1) {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* mutable state. Report accumulation flows through the `ConversionReport`
|
|
16
16
|
* argument supplied by the caller (the `flagsmith.ts` source).
|
|
17
17
|
*/
|
|
18
|
+
import { resolveKey } from '../../key-rewriter.js';
|
|
18
19
|
import { inferValueType, toQuonfigValue } from '../../quonfig-target/values.js';
|
|
19
20
|
const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
20
21
|
/** Flagsmith identity-override rule keys context attributes under this propertyName. */
|
|
@@ -33,8 +34,12 @@ export function flagOutputPath(name) {
|
|
|
33
34
|
export function segmentOutputPath(name) {
|
|
34
35
|
return `segments/${normalizeKey(name)}.json`;
|
|
35
36
|
}
|
|
37
|
+
// qfg-6na9.3: resolve every key through the per-run key rewriter so imported
|
|
38
|
+
// keys 100% conform to Policy A (charset + FS-floor). Used for the segment/flag
|
|
39
|
+
// key field AND the output path; the matching IN_SEG reference below resolves
|
|
40
|
+
// the same source string, so a rewritten segment key never dangles its rules.
|
|
36
41
|
function normalizeKey(key) {
|
|
37
|
-
return key
|
|
42
|
+
return resolveKey(key);
|
|
38
43
|
}
|
|
39
44
|
/** Env name → Quonfig env id slug (matches the source-module slugifier). */
|
|
40
45
|
function slugifyEnvName(name) {
|
|
@@ -662,7 +667,10 @@ function buildSegmentOverrideRule(segov, segmentName, bundle, ctx) {
|
|
|
662
667
|
criteria: [
|
|
663
668
|
{
|
|
664
669
|
operator: 'IN_SEG',
|
|
665
|
-
|
|
670
|
+
// qfg-6na9.3: resolve the referenced segment key through the same
|
|
671
|
+
// rewriter as the segment's own key def, so a sanitized/disambiguated
|
|
672
|
+
// segment key stays in sync here (no dangling targeting).
|
|
673
|
+
valueToMatch: { type: 'string', value: resolveKey(segmentName) },
|
|
666
674
|
},
|
|
667
675
|
],
|
|
668
676
|
value,
|
|
@@ -12,10 +12,12 @@ export declare function slugify(value: string): string;
|
|
|
12
12
|
export declare function isLegacyLogLevel(type: string): boolean;
|
|
13
13
|
export declare function normalizeLogLevelKey(key: string): string;
|
|
14
14
|
/**
|
|
15
|
-
* qfg-qhk1:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* qfg-qhk1 + qfg-6na9.3: resolve keys through the per-run key rewriter so the
|
|
16
|
+
* destination is always flat AND 100% Policy-A conformant (charset + FS-floor,
|
|
17
|
+
* with collision disambiguation). Launch keys are dotted identifiers, so this is
|
|
18
|
+
* a no-op in the common case; an odd key still gets made safe, and the matching
|
|
19
|
+
* IN_SEG reference (resolveSegmentRef, below) resolves the same source string so
|
|
20
|
+
* a rewritten segment key never dangles its rules.
|
|
19
21
|
*/
|
|
20
22
|
export declare function normalizeKey(key: string): string;
|
|
21
23
|
/**
|