@quonfig/cli 0.0.69 → 0.0.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -50
- package/dist/codegen/code-generators/base-generator.d.ts +8 -1
- package/dist/codegen/code-generators/base-generator.js +3 -1
- package/dist/codegen/code-generators/base-typescript-generator.d.ts +34 -1
- package/dist/codegen/code-generators/base-typescript-generator.js +78 -2
- package/dist/codegen/code-generators/node-typescript-generator.js +7 -12
- package/dist/codegen/code-generators/react-typescript-generator.js +9 -12
- package/dist/commands/generate.js +4 -2
- package/dist/commands/verify.js +4 -0
- package/dist/init/schema.js +11 -2
- package/dist/migrate/import-state.d.ts +3 -2
- package/dist/migrate/import-state.js +20 -7
- package/dist/migrate/key-rewriter.d.ts +70 -14
- package/dist/migrate/key-rewriter.js +186 -38
- package/dist/migrate/local-write.d.ts +10 -0
- package/dist/migrate/local-write.js +27 -1
- package/dist/migrate/push-to-cloud.js +24 -12
- package/dist/migrate/quonfig-target/ruleset.js +4 -1
- package/dist/migrate/source.d.ts +9 -0
- package/dist/migrate/sources/launch/translate.js +81 -1
- package/dist/migrate/sources/launchdarkly/translate.js +12 -2
- package/dist/migrate/sources/launchdarkly.js +15 -2
- package/dist/push/config-push-client.d.ts +3 -2
- package/dist/push/config-push-client.js +3 -2
- package/dist/util/get-valid-token.js +4 -2
- package/dist/util/policy-a-key.d.ts +15 -1
- package/dist/util/policy-a-key.js +25 -1
- package/dist/verify/standalone.d.ts +7 -0
- package/dist/verify/standalone.js +15 -9
- package/dist/verify/validate.js +93 -4
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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) {
|
package/dist/commands/verify.js
CHANGED
|
@@ -35,6 +35,10 @@ export default class Verify extends BaseCommand {
|
|
|
35
35
|
}
|
|
36
36
|
if (this.jsonEnabled()) {
|
|
37
37
|
if (!result.valid) {
|
|
38
|
+
// Print the JSON findings BEFORE exiting non-zero. `this.exit()` throws,
|
|
39
|
+
// so exiting first would swallow the very output `--json` exists to
|
|
40
|
+
// produce (qfg-ez47). Same order as `migrate doctor`: emit, then exit.
|
|
41
|
+
this.logJson(result);
|
|
38
42
|
this.exit(1);
|
|
39
43
|
}
|
|
40
44
|
return result;
|
package/dist/init/schema.js
CHANGED
|
@@ -238,9 +238,18 @@ export function storedConfigJsonSchema() {
|
|
|
238
238
|
key: {
|
|
239
239
|
type: 'string',
|
|
240
240
|
minLength: 1,
|
|
241
|
-
|
|
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,
|
|
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):
|
|
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
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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(
|
|
37
|
+
export declare function seedPersistedKeyPlan(plan: Readonly<KeyPlanData>): void;
|
|
18
38
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
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
|
|
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():
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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(
|
|
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
|
|
94
|
+
/** Find the first free variant of `base` (base, base-2, ...). */
|
|
45
95
|
function disambiguate(base) {
|
|
46
|
-
if (!
|
|
96
|
+
if (!isTaken(base, true))
|
|
47
97
|
return base;
|
|
48
98
|
for (let n = 2;; n++) {
|
|
49
|
-
const
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
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.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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
|
-
|
|
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 {};
|