@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.
@@ -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
- return { type: 'string', value: String(clause.values[0] ?? '') };
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.replaceAll('/', '.');
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
- valueToMatch: { type: 'string', value: segmentName },
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: Quonfig keys cannot contain `/` — keys are stored as flat dotted
16
- * filenames, and a `/` in a source key would create a nested directory tree
17
- * (data-loss-on-tombstone, cross-key collisions). Normalize `/` to `.` so the
18
- * destination is always flat.
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
  /**