@quonfig/cli 0.0.69 → 0.0.70

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.
@@ -1,5 +1,4 @@
1
1
  import { stripIndent } from 'common-tags';
2
- import camelCase from 'lodash.camelcase';
3
2
  import { z } from 'zod';
4
3
  import { ZodToTypescriptMapper } from '../language-mappers/zod-to-typescript-mapper.js';
5
4
  import { ZodToTypescriptReturnValueMapper } from '../language-mappers/zod-to-typescript-return-value-mapper.js';
@@ -78,17 +77,15 @@ export class ReactTypeScriptGenerator extends BaseTypescriptGenerator {
78
77
  return this.configurations().filter((config) => config.configType === 'FEATURE_FLAG' || config.sendToClientSdk === true);
79
78
  }
80
79
  generateAccessorMethods() {
81
- const uniqueMethods = {};
82
- const schemaTypes = this.filteredConfigurations().map((config) => {
83
- let methodName = camelCase(config.key);
84
- // If the method name starts with a digit, prefix it with an underscore to ensure method name is valid
85
- if (/^\d/.test(methodName)) {
86
- methodName = `_${methodName}`;
87
- }
88
- if (uniqueMethods[methodName]) {
89
- throw new Error(`Method '${methodName}' is already registered. Quonfig key ${config.key} conflicts with '${uniqueMethods[methodName]}'!`);
90
- }
91
- uniqueMethods[methodName] = config.key;
80
+ const configs = this.filteredConfigurations();
81
+ // qfg-hbuy.8: colliding camelCased identifiers get deterministic numeric
82
+ // suffixes (with a warning) instead of throwing — see the base class. Runs
83
+ // on the client-side-filtered set, so a collision among server-only keys
84
+ // does not affect (or warn in) the react output.
85
+ const { collisions, methodNameByKey } = this.assignAccessorMethodNames(configs.map((c) => c.key));
86
+ this.warnOnAccessorNameCollisions(collisions);
87
+ const schemaTypes = configs.map((config) => {
88
+ const methodName = methodNameByKey.get(config.key);
92
89
  if (config.hasFunction) {
93
90
  const returnValue = new ZodToTypescriptReturnValueMapper().resolveType(config.schema);
94
91
  return stripIndent `
@@ -256,11 +256,13 @@ Example quonfig.config.json:
256
256
  }
257
257
  }
258
258
  resolveGenerator(language, configFile) {
259
+ // warn (unlike verboseLog) is always visible — identifier-collision dedup
260
+ // notices (qfg-hbuy.8) must reach the user without --verbose.
259
261
  switch (language) {
260
262
  case SupportedLanguage.Node:
261
- return new NodeTypeScriptGenerator({ configFile, log: this.verboseLog });
263
+ return new NodeTypeScriptGenerator({ configFile, log: this.verboseLog, warn: (m) => this.warn(m) });
262
264
  case SupportedLanguage.React:
263
- return new ReactTypeScriptGenerator({ configFile, log: this.verboseLog });
265
+ return new ReactTypeScriptGenerator({ configFile, log: this.verboseLog, warn: (m) => this.warn(m) });
264
266
  }
265
267
  }
266
268
  resolveLanguage(languageTarget) {
@@ -238,9 +238,18 @@ export function storedConfigJsonSchema() {
238
238
  key: {
239
239
  type: 'string',
240
240
  minLength: 1,
241
- maxLength: 512,
241
+ // Policy A key rule (qfg-hbuy.9): tightened from the old loose
242
+ // maxLength:512 + `^[^/\\]+$` to match what the prod qfg-verify hook
243
+ // (and app-quonfig PolicyAKeySchema) actually enforce, so a file that
244
+ // validates green against this published schema in a customer's editor
245
+ // is no longer hard-rejected at push time. Charset `^[A-Za-z0-9._-]+$`,
246
+ // 200-char cap. (The hook additionally enforces the FS-safety floor —
247
+ // no leading/trailing dot, no Windows reserved device names — which
248
+ // JSON Schema's pattern can't express cleanly; the charset + cap are
249
+ // the parts that fit here.)
250
+ maxLength: 200,
242
251
  not: { const: 'new' },
243
- pattern: '^[^/\\\\]+$',
252
+ pattern: '^[A-Za-z0-9._-]+$',
244
253
  description: 'Unique identifier. Must match the filename (without .json).',
245
254
  },
246
255
  type: {
@@ -1,3 +1,4 @@
1
+ import type { KeyPlanData } from './key-rewriter.js';
1
2
  export interface ImportState {
2
3
  lastProcessedAt?: number | string;
3
4
  source: string;
@@ -10,8 +11,8 @@ export declare class CrossSourceError extends Error {
10
11
  }
11
12
  export declare function writeImportState(outputDir: string, state: ImportState): void;
12
13
  export declare function readImportState(outputDir: string): ImportState | null;
13
- export declare function writeKeyPlan(outputDir: string, keys: Record<string, string>): void;
14
+ export declare function writeKeyPlan(outputDir: string, plan: KeyPlanData): void;
14
15
  /** The persisted plan, or null when absent/unreadable (treat as first run). */
15
- export declare function readKeyPlan(outputDir: string): null | Record<string, string>;
16
+ export declare function readKeyPlan(outputDir: string): KeyPlanData | null;
16
17
  export declare function assertSourceMatches(outputDir: string, requestedSource: string): void;
17
18
  export declare function removeQfFromGitignore(outputDir: string): void;
@@ -38,13 +38,19 @@ export function readImportState(outputDir) {
38
38
  function keyPlanPath(outputDir) {
39
39
  return path.join(outputDir, '.qf', 'key-plan.json');
40
40
  }
41
- export function writeKeyPlan(outputDir, keys) {
41
+ function sortRecord(record) {
42
+ const sorted = {};
43
+ for (const source of Object.keys(record).sort())
44
+ sorted[source] = record[source];
45
+ return sorted;
46
+ }
47
+ export function writeKeyPlan(outputDir, plan) {
42
48
  const filePath = keyPlanPath(outputDir);
43
49
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
44
- const sorted = {};
45
- for (const source of Object.keys(keys).sort())
46
- sorted[source] = keys[source];
47
- const serialized = { keys: sorted, version: 1 };
50
+ const keys = sortRecord(plan.keys);
51
+ const serialized = Object.keys(plan.segmentKeys).length > 0
52
+ ? { keys, segmentKeys: sortRecord(plan.segmentKeys), version: 2 }
53
+ : { keys, version: 1 };
48
54
  fs.writeFileSync(filePath, JSON.stringify(serialized, null, 2) + '\n', 'utf8');
49
55
  }
50
56
  /** The persisted plan, or null when absent/unreadable (treat as first run). */
@@ -56,10 +62,17 @@ export function readKeyPlan(outputDir) {
56
62
  const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
57
63
  if (typeof parsed?.keys !== 'object' || parsed.keys === null || Array.isArray(parsed.keys))
58
64
  return null;
59
- const out = {};
65
+ const out = { keys: {}, segmentKeys: {} };
60
66
  for (const [source, final] of Object.entries(parsed.keys)) {
61
67
  if (typeof final === 'string')
62
- out[source] = final;
68
+ out.keys[source] = final;
69
+ }
70
+ // Version-1 files have no segmentKeys — read as empty (backward compat).
71
+ if (typeof parsed.segmentKeys === 'object' && parsed.segmentKeys !== null && !Array.isArray(parsed.segmentKeys)) {
72
+ for (const [source, final] of Object.entries(parsed.segmentKeys)) {
73
+ if (typeof final === 'string')
74
+ out.segmentKeys[source] = final;
75
+ }
63
76
  }
64
77
  return out;
65
78
  }
@@ -6,22 +6,51 @@ export interface KeyRewrite {
6
6
  /** The original source-system key. */
7
7
  source: string;
8
8
  }
9
+ /**
10
+ * qfg-hbuy.10: source-side key namespaces. LaunchDarkly flags and segments are
11
+ * SEPARATE key namespaces, so a project can legitimately carry a flag and a
12
+ * segment with the same source key. Quonfig keys are globally unique across
13
+ * type trees, so the two need distinct finals: the flag (default namespace)
14
+ * keeps the clean name and the segment is deterministically suffixed
15
+ * `-segment`. Everything that isn't a segment plans in `default`.
16
+ */
17
+ export type KeyNamespace = 'default' | 'segment';
18
+ /**
19
+ * The complete persisted key plan (.qf/key-plan.json): the default-namespace
20
+ * map plus the segment-namespace map. `segmentKeys` only carries segments that
21
+ * COLLIDED with a default-namespace key and were suffixed — a lone segment
22
+ * plans in the default flow and stays in `keys`, exactly as before
23
+ * qfg-hbuy.10, so existing version-1 plans keep resolving unchanged.
24
+ */
25
+ export interface KeyPlanData {
26
+ keys: Record<string, string>;
27
+ segmentKeys: Record<string, string>;
28
+ }
9
29
  export declare function resetKeyRewriter(): void;
10
30
  /**
11
- * Seed the rewriter with the complete source->final map persisted by previous
31
+ * Seed the rewriter with the complete source->final maps persisted by previous
12
32
  * runs. Every persisted final is immediately marked taken (case-insensitively)
13
33
  * so keys planned later this run can never claim one. Call after
14
34
  * `resetKeyRewriter()` and before `planKeyRewrites()` — `planKeyRewritesForChanges`
15
35
  * does this ordering for you.
16
36
  */
17
- export declare function seedPersistedKeyPlan(keys: Readonly<Record<string, string>>): void;
37
+ export declare function seedPersistedKeyPlan(plan: Readonly<KeyPlanData>): void;
18
38
  /**
19
- * Pre-pass: compute the final key for every source key. Three passes, each
20
- * sorted so every assignment is deterministic and independent of the order
21
- * changes were fetched in:
39
+ * qfg-hbuy.12: seed the rewriter with keys that ALREADY EXIST in the target
40
+ * workspace (push mode clones it; local mode may reuse a pulled dir). Call
41
+ * after `seedPersistedKeyPlan()` — persisted mappings stay authoritative,
42
+ * workspace seeding only fills in what the plan doesn't cover. See
43
+ * `existingLower` for the exact/case-variant semantics.
44
+ */
45
+ export declare function seedExistingWorkspaceKeys(keys: Iterable<string>): void;
46
+ /**
47
+ * Pre-pass: compute the final key for every source key. Sorted passes so every
48
+ * assignment is deterministic and independent of the order changes were
49
+ * fetched in:
22
50
  *
23
51
  * 0. Keys mapped by a PREVIOUS run keep their persisted final verbatim (see
24
52
  * `seedPersistedKeyPlan`) — full and delta runs must resolve identically.
53
+ * Applies to both namespaces.
25
54
  * 1. Keys that are ALREADY fully valid (sanitize is an identity: they pass
26
55
  * Policy A and the FS-floor) claim their own names FIRST and are never
27
56
  * renamed to make room for sanitized junk — customer code calling
@@ -32,33 +61,55 @@ export declare function seedPersistedKeyPlan(keys: Readonly<Record<string, strin
32
61
  * and the other is suffixed.
33
62
  * 2. Keys that needed sanitizing then disambiguate AROUND the valid ones
34
63
  * (-2, -3, ...).
64
+ * 3. qfg-hbuy.10: SEGMENTS whose source key is also claimed by the default
65
+ * namespace this run (an LD flag and segment sharing one key) get the
66
+ * deterministic `-segment` suffix, disambiguating around every name
67
+ * assigned above — a suffixed segment can never steal a different valid
68
+ * key's name. Segments with no such collision plan in the default flow
69
+ * (passes 0-2) and keep their clean names.
35
70
  */
36
- export declare function planKeyRewrites(sourceKeys: Iterable<string>): void;
71
+ export declare function planKeyRewrites(sourceKeys: Iterable<string>, segmentSourceKeys?: Iterable<string>): void;
37
72
  /**
38
73
  * The final key for `sourceKey`. Uses the planned map when present, then the
39
74
  * PERSISTED map (a delta run's flag can reference a segment that was migrated
40
75
  * in the full run but is absent from the delta's change set), else a pure
41
76
  * sanitize (no disambiguation, no mutation) as a safe fallback.
77
+ *
78
+ * Segment definitions and by-key segment references (IN_SEG/NOT_IN_SEG) pass
79
+ * `namespace: 'segment'`: the segment-namespace maps are consulted first, then
80
+ * resolution falls back to the default namespace — a segment that never
81
+ * collided with a flag plans in the default flow and lives there.
82
+ */
83
+ export declare function resolveKey(sourceKey: string, namespace?: KeyNamespace): string;
84
+ /**
85
+ * qfg-hbuy.11: the final key for `sourceKey` ONLY when this run (or a
86
+ * persisted plan) actually mapped it, else null. For rewriting by-key
87
+ * REFERENCES whose target may legitimately live outside the import — a
88
+ * config's `schemaKey` or a value's `decryptWith` can point at a pre-existing
89
+ * workspace key the migrator does not own; those must be left untouched
90
+ * rather than speculatively sanitized (default namespace only — both fields
91
+ * reference configs/schemas, never segments).
42
92
  */
43
- export declare function resolveKey(sourceKey: string): string;
93
+ export declare function resolveMappedKey(sourceKey: string): null | string;
44
94
  /** Every key that was actually rewritten (conforming keys are omitted). */
45
95
  export declare function getKeyRewrites(): KeyRewrite[];
46
96
  /**
47
- * The COMPLETE source->final map to persist to `.qf/key-plan.json`: every key
97
+ * The COMPLETE source->final maps to persist to `.qf/key-plan.json`: every key
48
98
  * planned this run (unchanged ones included) merged over everything persisted
49
99
  * by previous runs, sorted by source key for stable on-disk diffs.
50
100
  */
51
- export declare function getFullKeyPlan(): Record<string, string>;
101
+ export declare function getFullKeyPlan(): KeyPlanData;
52
102
  /**
53
103
  * Run-level pre-pass: reset the rewriter, seed the persisted plan from any
54
104
  * previous run, and plan every source key BEFORE any change is translated, so
55
105
  * both key-definition and by-key reference sites resolve against the same
56
- * fully-disambiguated map. Structurally typed on `{key?}` to stay decoupled
57
- * from LegacyChange.
106
+ * fully-disambiguated map. Structurally typed on `{key?, keyNamespace?}` to
107
+ * stay decoupled from LegacyChange.
58
108
  */
59
109
  export declare function planKeyRewritesForChanges(changes: ReadonlyArray<{
60
110
  key?: string;
61
- }>, persistedKeys?: Readonly<Record<string, string>>): void;
111
+ keyNamespace?: string;
112
+ }>, persistedKeys?: Readonly<KeyPlanData>, existingKeys?: Iterable<string>): void;
62
113
  /**
63
114
  * `--strict-keys` escape hatch: refuse to migrate if any source key would need
64
115
  * rewriting, so a customer who requires byte-identical keys can clean up the
@@ -72,11 +123,16 @@ export declare class StrictKeysError extends Error {
72
123
  * Plan the rewrites for a run and, when `strict`, throw `StrictKeysError` if any
73
124
  * key would be rewritten. Call once at the top of each migrate orchestrator
74
125
  * before translating. `persistedKeys` (from `readKeyPlan`) makes previously
75
- * mapped keys resolve exactly as they did on the run that mapped them.
126
+ * mapped keys resolve exactly as they did on the run that mapped them;
127
+ * `existingKeys` (qfg-hbuy.12: the keys already on disk in the target
128
+ * workspace) makes case-variant collisions rename deterministically instead
129
+ * of aborting at the verify gate, while byte-equal matches keep overwriting.
76
130
  */
77
131
  export declare function preflightKeyRewrites(changes: ReadonlyArray<{
78
132
  key?: string;
133
+ keyNamespace?: string;
79
134
  }>, opts?: {
80
- persistedKeys?: Readonly<Record<string, string>>;
135
+ existingKeys?: Iterable<string>;
136
+ persistedKeys?: Readonly<KeyPlanData>;
81
137
  strict?: boolean;
82
138
  }): void;
@@ -22,53 +22,109 @@
22
22
  * unit calls to a source's path builders stay referentially transparent.
23
23
  */
24
24
  import { sanitizePolicyAKey } from './key-sanitize.js';
25
- const state = { bySource: new Map(), persisted: new Map(), takenLower: new Map() };
25
+ const state = {
26
+ bySource: new Map(),
27
+ bySourceSegment: new Map(),
28
+ existingLower: new Map(),
29
+ persisted: new Map(),
30
+ persistedSegment: new Map(),
31
+ takenLower: new Map(),
32
+ };
26
33
  export function resetKeyRewriter() {
27
34
  state.bySource = new Map();
35
+ state.bySourceSegment = new Map();
36
+ state.existingLower = new Map();
28
37
  state.persisted = new Map();
38
+ state.persistedSegment = new Map();
29
39
  state.takenLower = new Map();
30
40
  }
31
41
  /**
32
- * Seed the rewriter with the complete source->final map persisted by previous
42
+ * Seed the rewriter with the complete source->final maps persisted by previous
33
43
  * runs. Every persisted final is immediately marked taken (case-insensitively)
34
44
  * so keys planned later this run can never claim one. Call after
35
45
  * `resetKeyRewriter()` and before `planKeyRewrites()` — `planKeyRewritesForChanges`
36
46
  * does this ordering for you.
37
47
  */
38
- export function seedPersistedKeyPlan(keys) {
39
- for (const [sourceKey, final] of Object.entries(keys)) {
48
+ export function seedPersistedKeyPlan(plan) {
49
+ for (const [sourceKey, final] of Object.entries(plan.keys)) {
40
50
  state.persisted.set(sourceKey, final);
41
51
  state.takenLower.set(final.toLowerCase(), sourceKey);
42
52
  }
53
+ for (const [sourceKey, final] of Object.entries(plan.segmentKeys)) {
54
+ state.persistedSegment.set(sourceKey, final);
55
+ state.takenLower.set(final.toLowerCase(), sourceKey);
56
+ }
57
+ }
58
+ /**
59
+ * qfg-hbuy.12: seed the rewriter with keys that ALREADY EXIST in the target
60
+ * workspace (push mode clones it; local mode may reuse a pulled dir). Call
61
+ * after `seedPersistedKeyPlan()` — persisted mappings stay authoritative,
62
+ * workspace seeding only fills in what the plan doesn't cover. See
63
+ * `existingLower` for the exact/case-variant semantics.
64
+ */
65
+ export function seedExistingWorkspaceKeys(keys) {
66
+ for (const key of keys) {
67
+ const lower = key.toLowerCase();
68
+ if (!state.existingLower.has(lower))
69
+ state.existingLower.set(lower, key);
70
+ }
71
+ }
72
+ /** Append `suffix` to `base`, truncating `base` so the result stays <= 200 chars. */
73
+ function withSuffix(base, suffix) {
74
+ const room = 200 - suffix.length;
75
+ return (base.length > room ? base.slice(0, room) : base) + suffix;
76
+ }
77
+ /**
78
+ * Is `candidate` unavailable? Keys claimed this run / by the persisted plan
79
+ * (`takenLower`) always block. Keys that merely EXIST in the target workspace
80
+ * block case-variants and — when `allowExactExistingMatch` is false — even
81
+ * byte-equal candidates: a key claiming its OWN name may overwrite its
82
+ * existing file (re-migration), but a disambiguation suffix must never land
83
+ * on an unrelated existing key (qfg-hbuy.12).
84
+ */
85
+ function isTaken(candidate, allowExactExistingMatch) {
86
+ const lower = candidate.toLowerCase();
87
+ if (state.takenLower.has(lower))
88
+ return true;
89
+ const existing = state.existingLower.get(lower);
90
+ if (existing === undefined)
91
+ return false;
92
+ return allowExactExistingMatch ? existing !== candidate : true;
43
93
  }
44
- /** Find the first case-insensitively-free variant of `base` (base, base-2, ...). */
94
+ /** Find the first free variant of `base` (base, base-2, ...). */
45
95
  function disambiguate(base) {
46
- if (!state.takenLower.has(base.toLowerCase()))
96
+ if (!isTaken(base, true))
47
97
  return base;
48
98
  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()))
99
+ const candidate = withSuffix(base, `-${n}`);
100
+ if (!isTaken(candidate, false))
53
101
  return candidate;
54
102
  }
55
103
  }
56
- /** Disambiguate `base`, record the mapping, and mark the final taken. */
57
- function assignFinal(sourceKey, base, sanitizeReasons) {
104
+ /** Disambiguate `base`, record the mapping in `map`, and mark the final taken. */
105
+ function assignFinal(map, sourceKey, base, sanitizeReasons) {
58
106
  const final = disambiguate(base);
59
107
  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 });
108
+ if (final !== base) {
109
+ // Name the blocker when it was a case-variant key already in the target
110
+ // workspace (qfg-hbuy.12) — that report line is the customer's cue that
111
+ // the import collided with something they created outside the migration.
112
+ const existing = state.existingLower.get(base.toLowerCase());
113
+ reasons.push(existing !== undefined && existing !== base && !state.takenLower.has(base.toLowerCase())
114
+ ? `renamed: the target workspace already has "${existing}", which differs only by case`
115
+ : 'appended a numeric suffix to keep the key unique');
116
+ }
117
+ map.set(sourceKey, { final, reasons, source: sourceKey });
63
118
  state.takenLower.set(final.toLowerCase(), sourceKey);
64
119
  }
65
120
  /**
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:
121
+ * Pre-pass: compute the final key for every source key. Sorted passes so every
122
+ * assignment is deterministic and independent of the order changes were
123
+ * fetched in:
69
124
  *
70
125
  * 0. Keys mapped by a PREVIOUS run keep their persisted final verbatim (see
71
126
  * `seedPersistedKeyPlan`) — full and delta runs must resolve identically.
127
+ * Applies to both namespaces.
72
128
  * 1. Keys that are ALREADY fully valid (sanitize is an identity: they pass
73
129
  * Policy A and the FS-floor) claim their own names FIRST and are never
74
130
  * renamed to make room for sanitized junk — customer code calling
@@ -79,9 +135,24 @@ function assignFinal(sourceKey, base, sanitizeReasons) {
79
135
  * and the other is suffixed.
80
136
  * 2. Keys that needed sanitizing then disambiguate AROUND the valid ones
81
137
  * (-2, -3, ...).
138
+ * 3. qfg-hbuy.10: SEGMENTS whose source key is also claimed by the default
139
+ * namespace this run (an LD flag and segment sharing one key) get the
140
+ * deterministic `-segment` suffix, disambiguating around every name
141
+ * assigned above — a suffixed segment can never steal a different valid
142
+ * key's name. Segments with no such collision plan in the default flow
143
+ * (passes 0-2) and keep their clean names.
82
144
  */
83
- export function planKeyRewrites(sourceKeys) {
84
- const fresh = [...new Set(sourceKeys)].sort().filter((k) => !state.bySource.has(k));
145
+ export function planKeyRewrites(sourceKeys, segmentSourceKeys = []) {
146
+ const defaultKeys = [...new Set(sourceKeys)];
147
+ const freshSegments = [...new Set(segmentSourceKeys)].sort().filter((k) => !state.bySourceSegment.has(k));
148
+ // Split segment keys: only a segment whose source key is ALSO claimed by the
149
+ // default namespace this run — or that a previous run already namespaced
150
+ // (sticky, so re-runs stay stable) — needs its own namespace. Everything
151
+ // else plans in the default flow, exactly as before qfg-hbuy.10.
152
+ const defaultKeySet = new Set([...defaultKeys, ...state.bySource.keys()]);
153
+ const scopedSegments = freshSegments.filter((k) => state.persistedSegment.has(k) || defaultKeySet.has(k));
154
+ const plainSegments = freshSegments.filter((k) => !state.persistedSegment.has(k) && !defaultKeySet.has(k));
155
+ const fresh = [...new Set([...defaultKeys, ...plainSegments])].sort().filter((k) => !state.bySource.has(k));
85
156
  // Pass 0: previously-mapped keys resolve to their persisted final, always.
86
157
  const unpersisted = [];
87
158
  for (const sourceKey of fresh) {
@@ -97,6 +168,22 @@ export function planKeyRewrites(sourceKeys) {
97
168
  state.bySource.set(sourceKey, { final: persistedFinal, reasons, source: sourceKey });
98
169
  state.takenLower.set(persistedFinal.toLowerCase(), sourceKey);
99
170
  }
171
+ // Pass 0 (segment namespace): persisted segment mappings are just as
172
+ // authoritative — record them so the report and full plan carry them.
173
+ const unpersistedSegments = [];
174
+ for (const sourceKey of scopedSegments) {
175
+ const persistedFinal = state.persistedSegment.get(sourceKey);
176
+ if (persistedFinal === undefined) {
177
+ unpersistedSegments.push(sourceKey);
178
+ continue;
179
+ }
180
+ state.bySourceSegment.set(sourceKey, {
181
+ final: persistedFinal,
182
+ reasons: ['kept the segment key assigned by a previous import run'],
183
+ source: sourceKey,
184
+ });
185
+ state.takenLower.set(persistedFinal.toLowerCase(), sourceKey);
186
+ }
100
187
  // Pass 1: already-valid keys claim their own names.
101
188
  const needsSanitizing = [];
102
189
  for (const sourceKey of unpersisted) {
@@ -105,12 +192,23 @@ export function planKeyRewrites(sourceKeys) {
105
192
  needsSanitizing.push({ sanitized, sourceKey });
106
193
  }
107
194
  else {
108
- assignFinal(sourceKey, sourceKey, []);
195
+ assignFinal(state.bySource, sourceKey, sourceKey, []);
109
196
  }
110
197
  }
111
198
  // Pass 2: sanitized keys disambiguate around them.
112
199
  for (const { sanitized, sourceKey } of needsSanitizing) {
113
- assignFinal(sourceKey, sanitized.key, sanitized.reasons);
200
+ assignFinal(state.bySource, sourceKey, sanitized.key, sanitized.reasons);
201
+ }
202
+ // Pass 3 (qfg-hbuy.10): colliding segments take the '-segment' suffix,
203
+ // disambiguating around every name assigned above.
204
+ for (const sourceKey of unpersistedSegments) {
205
+ const sanitized = sanitizePolicyAKey(sourceKey);
206
+ const base = withSuffix(sanitized.key, '-segment');
207
+ const reasons = [
208
+ ...sanitized.reasons,
209
+ `suffixed "-segment": a feature flag in this import shares the source key "${sourceKey}"`,
210
+ ];
211
+ assignFinal(state.bySourceSegment, sourceKey, base, reasons);
114
212
  }
115
213
  }
116
214
  /**
@@ -118,8 +216,21 @@ export function planKeyRewrites(sourceKeys) {
118
216
  * PERSISTED map (a delta run's flag can reference a segment that was migrated
119
217
  * in the full run but is absent from the delta's change set), else a pure
120
218
  * sanitize (no disambiguation, no mutation) as a safe fallback.
219
+ *
220
+ * Segment definitions and by-key segment references (IN_SEG/NOT_IN_SEG) pass
221
+ * `namespace: 'segment'`: the segment-namespace maps are consulted first, then
222
+ * resolution falls back to the default namespace — a segment that never
223
+ * collided with a flag plans in the default flow and lives there.
121
224
  */
122
- export function resolveKey(sourceKey) {
225
+ export function resolveKey(sourceKey, namespace = 'default') {
226
+ if (namespace === 'segment') {
227
+ const plannedSegment = state.bySourceSegment.get(sourceKey);
228
+ if (plannedSegment)
229
+ return plannedSegment.final;
230
+ const persistedSegment = state.persistedSegment.get(sourceKey);
231
+ if (persistedSegment !== undefined)
232
+ return persistedSegment;
233
+ }
123
234
  const planned = state.bySource.get(sourceKey);
124
235
  if (planned)
125
236
  return planned.final;
@@ -128,36 +239,70 @@ export function resolveKey(sourceKey) {
128
239
  return persisted;
129
240
  return sanitizePolicyAKey(sourceKey).key;
130
241
  }
242
+ /**
243
+ * qfg-hbuy.11: the final key for `sourceKey` ONLY when this run (or a
244
+ * persisted plan) actually mapped it, else null. For rewriting by-key
245
+ * REFERENCES whose target may legitimately live outside the import — a
246
+ * config's `schemaKey` or a value's `decryptWith` can point at a pre-existing
247
+ * workspace key the migrator does not own; those must be left untouched
248
+ * rather than speculatively sanitized (default namespace only — both fields
249
+ * reference configs/schemas, never segments).
250
+ */
251
+ export function resolveMappedKey(sourceKey) {
252
+ const planned = state.bySource.get(sourceKey);
253
+ if (planned)
254
+ return planned.final;
255
+ return state.persisted.get(sourceKey) ?? null;
256
+ }
131
257
  /** Every key that was actually rewritten (conforming keys are omitted). */
132
258
  export function getKeyRewrites() {
133
- return [...state.bySource.values()].filter((r) => r.final !== r.source);
259
+ return [...state.bySource.values(), ...state.bySourceSegment.values()].filter((r) => r.final !== r.source);
134
260
  }
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)
261
+ /** Persisted map merged under this run's planned finals, sorted by source key. */
262
+ function sortMerged(persisted, planned) {
263
+ const merged = new Map(persisted);
264
+ for (const [source, rewrite] of planned)
143
265
  merged.set(source, rewrite.final);
144
266
  const out = {};
145
267
  for (const source of [...merged.keys()].sort())
146
268
  out[source] = merged.get(source);
147
269
  return out;
148
270
  }
271
+ /**
272
+ * The COMPLETE source->final maps to persist to `.qf/key-plan.json`: every key
273
+ * planned this run (unchanged ones included) merged over everything persisted
274
+ * by previous runs, sorted by source key for stable on-disk diffs.
275
+ */
276
+ export function getFullKeyPlan() {
277
+ return {
278
+ keys: sortMerged(state.persisted, state.bySource),
279
+ segmentKeys: sortMerged(state.persistedSegment, state.bySourceSegment),
280
+ };
281
+ }
149
282
  /**
150
283
  * Run-level pre-pass: reset the rewriter, seed the persisted plan from any
151
284
  * previous run, and plan every source key BEFORE any change is translated, so
152
285
  * 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.
286
+ * fully-disambiguated map. Structurally typed on `{key?, keyNamespace?}` to
287
+ * stay decoupled from LegacyChange.
155
288
  */
156
- export function planKeyRewritesForChanges(changes, persistedKeys) {
289
+ export function planKeyRewritesForChanges(changes, persistedKeys, existingKeys) {
157
290
  resetKeyRewriter();
158
291
  if (persistedKeys)
159
292
  seedPersistedKeyPlan(persistedKeys);
160
- planKeyRewrites(changes.flatMap((c) => (typeof c.key === 'string' ? [c.key] : [])));
293
+ if (existingKeys)
294
+ seedExistingWorkspaceKeys(existingKeys);
295
+ const defaultKeys = [];
296
+ const segmentKeys = [];
297
+ for (const change of changes) {
298
+ if (typeof change.key !== 'string')
299
+ continue;
300
+ if (change.keyNamespace === 'segment')
301
+ segmentKeys.push(change.key);
302
+ else
303
+ defaultKeys.push(change.key);
304
+ }
305
+ planKeyRewrites(defaultKeys, segmentKeys);
161
306
  }
162
307
  /**
163
308
  * `--strict-keys` escape hatch: refuse to migrate if any source key would need
@@ -178,10 +323,13 @@ export class StrictKeysError extends Error {
178
323
  * Plan the rewrites for a run and, when `strict`, throw `StrictKeysError` if any
179
324
  * key would be rewritten. Call once at the top of each migrate orchestrator
180
325
  * before translating. `persistedKeys` (from `readKeyPlan`) makes previously
181
- * mapped keys resolve exactly as they did on the run that mapped them.
326
+ * mapped keys resolve exactly as they did on the run that mapped them;
327
+ * `existingKeys` (qfg-hbuy.12: the keys already on disk in the target
328
+ * workspace) makes case-variant collisions rename deterministically instead
329
+ * of aborting at the verify gate, while byte-equal matches keep overwriting.
182
330
  */
183
331
  export function preflightKeyRewrites(changes, opts) {
184
- planKeyRewritesForChanges(changes, opts?.persistedKeys);
332
+ planKeyRewritesForChanges(changes, opts?.persistedKeys, opts?.existingKeys);
185
333
  if (opts?.strict) {
186
334
  const rewrites = getKeyRewrites();
187
335
  if (rewrites.length > 0)
@@ -110,4 +110,14 @@ export declare const buildAuditFinalCommit: (opts: BuildAuditFinalCommitOptions)
110
110
  * only the last slice.
111
111
  */
112
112
  export declare const collectLivePathsOnDisk: (dir: string) => string[];
113
+ /**
114
+ * qfg-hbuy.12: every key ALREADY present in the target workspace on disk —
115
+ * used to seed the key rewriter so an import that case-collides with an
116
+ * existing key (incoming `Foo`, workspace `foo`) renames deterministically
117
+ * instead of aborting at the verify gate, while a byte-equal match keeps
118
+ * today's silent-overwrite re-migration behavior. Scans every validated
119
+ * content dir (uniqueness is pooled per workspace, so schemas-protected/
120
+ * counts too, even though the migrator never writes there).
121
+ */
122
+ export declare const collectExistingWorkspaceKeys: (dir: string) => string[];
113
123
  export {};
@@ -186,8 +186,11 @@ export const applyLocalMigration = async (opts) => {
186
186
  // not per-commit) so key-def and reference sites resolve identically, then
187
187
  // enforce --strict-keys before writing anything. Mappings persisted by a
188
188
  // previous run (.qf/key-plan.json) are authoritative so a delta run cannot
189
- // replan a subset and land on different finals.
189
+ // replan a subset and land on different finals. qfg-hbuy.12: keys already
190
+ // on disk (a reused/pulled workspace dir) seed the rewriter so case-variant
191
+ // collisions rename instead of producing an unpushable workspace.
190
192
  preflightKeyRewrites(opts.changes, {
193
+ existingKeys: collectExistingWorkspaceKeys(opts.localDir),
191
194
  persistedKeys: readKeyPlan(opts.localDir) ?? undefined,
192
195
  strict: opts.strictKeys,
193
196
  });
@@ -397,3 +400,26 @@ export const collectLivePathsOnDisk = (dir) => {
397
400
  }
398
401
  return out;
399
402
  };
403
+ /**
404
+ * qfg-hbuy.12: every key ALREADY present in the target workspace on disk —
405
+ * used to seed the key rewriter so an import that case-collides with an
406
+ * existing key (incoming `Foo`, workspace `foo`) renames deterministically
407
+ * instead of aborting at the verify gate, while a byte-equal match keeps
408
+ * today's silent-overwrite re-migration behavior. Scans every validated
409
+ * content dir (uniqueness is pooled per workspace, so schemas-protected/
410
+ * counts too, even though the migrator never writes there).
411
+ */
412
+ export const collectExistingWorkspaceKeys = (dir) => {
413
+ const prefixes = ['feature-flags', 'configs', 'segments', 'schemas', 'schemas-protected', 'log-levels'];
414
+ const out = [];
415
+ for (const prefix of prefixes) {
416
+ const typeDir = path.join(dir, prefix);
417
+ if (!fs.existsSync(typeDir))
418
+ continue;
419
+ for (const entry of fs.readdirSync(typeDir)) {
420
+ if (entry.endsWith('.json') && !entry.startsWith('.'))
421
+ out.push(entry.slice(0, -'.json'.length));
422
+ }
423
+ }
424
+ return out;
425
+ };