@quonfig/cli 0.0.65 → 0.0.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -51
- package/dist/commands/create.js +9 -0
- package/dist/commands/migrate.d.ts +1 -0
- package/dist/commands/migrate.js +10 -0
- package/dist/migrate/key-rewriter.d.ts +50 -0
- package/dist/migrate/key-rewriter.js +109 -0
- package/dist/migrate/key-sanitize.d.ts +13 -0
- package/dist/migrate/key-sanitize.js +88 -0
- package/dist/migrate/local-write.d.ts +2 -0
- package/dist/migrate/local-write.js +7 -0
- package/dist/migrate/migration-report.d.ts +14 -0
- package/dist/migrate/migration-report.js +32 -0
- package/dist/migrate/push-to-cloud.d.ts +2 -0
- package/dist/migrate/push-to-cloud.js +6 -0
- package/dist/migrate/quonfig-target/ruleset.js +5 -1
- package/dist/migrate/sources/flagsmith/translate.js +10 -2
- package/dist/migrate/sources/launch/translate.d.ts +6 -4
- package/dist/migrate/sources/launch/translate.js +25 -5
- package/dist/migrate/sources/launchdarkly/translate.js +7 -4
- package/dist/util/policy-a-key.d.ts +26 -0
- package/dist/util/policy-a-key.js +42 -0
- package/dist/verify/validate.js +18 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/oclif.manifest.json +158 -152
- package/package.json +8 -2
|
@@ -0,0 +1,109 @@
|
|
|
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(), takenLower: new Map() };
|
|
26
|
+
export function resetKeyRewriter() {
|
|
27
|
+
state.bySource = new Map();
|
|
28
|
+
state.takenLower = new Map();
|
|
29
|
+
}
|
|
30
|
+
/** Find the first case-insensitively-free variant of `base` (base, base-2, ...). */
|
|
31
|
+
function disambiguate(base) {
|
|
32
|
+
if (!state.takenLower.has(base.toLowerCase()))
|
|
33
|
+
return base;
|
|
34
|
+
for (let n = 2;; n++) {
|
|
35
|
+
const suffix = `-${n}`;
|
|
36
|
+
const room = 200 - suffix.length;
|
|
37
|
+
const candidate = (base.length > room ? base.slice(0, room) : base) + suffix;
|
|
38
|
+
if (!state.takenLower.has(candidate.toLowerCase()))
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pre-pass: compute the final key for every source key. Sorted first so the
|
|
44
|
+
* assignment of the un-suffixed base name on a collision is deterministic and
|
|
45
|
+
* independent of the order changes were fetched in.
|
|
46
|
+
*/
|
|
47
|
+
export function planKeyRewrites(sourceKeys) {
|
|
48
|
+
for (const sourceKey of [...new Set(sourceKeys)].sort()) {
|
|
49
|
+
if (state.bySource.has(sourceKey))
|
|
50
|
+
continue;
|
|
51
|
+
const san = sanitizePolicyAKey(sourceKey);
|
|
52
|
+
const final = disambiguate(san.key);
|
|
53
|
+
const reasons = [...san.reasons];
|
|
54
|
+
if (final !== san.key)
|
|
55
|
+
reasons.push('appended a numeric suffix to keep the key unique');
|
|
56
|
+
state.bySource.set(sourceKey, { final, reasons, source: sourceKey });
|
|
57
|
+
state.takenLower.set(final.toLowerCase(), sourceKey);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The final key for `sourceKey`. Uses the planned map when present, else a pure
|
|
62
|
+
* sanitize (no disambiguation, no mutation) as a safe fallback.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveKey(sourceKey) {
|
|
65
|
+
const planned = state.bySource.get(sourceKey);
|
|
66
|
+
return planned ? planned.final : sanitizePolicyAKey(sourceKey).key;
|
|
67
|
+
}
|
|
68
|
+
/** Every key that was actually rewritten (conforming keys are omitted). */
|
|
69
|
+
export function getKeyRewrites() {
|
|
70
|
+
return [...state.bySource.values()].filter((r) => r.final !== r.source);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Run-level pre-pass: reset the rewriter and plan every source key BEFORE any
|
|
74
|
+
* change is translated, so both key-definition and by-key reference sites
|
|
75
|
+
* resolve against the same fully-disambiguated map. Structurally typed on
|
|
76
|
+
* `{key?}` to stay decoupled from LegacyChange.
|
|
77
|
+
*/
|
|
78
|
+
export function planKeyRewritesForChanges(changes) {
|
|
79
|
+
resetKeyRewriter();
|
|
80
|
+
planKeyRewrites(changes.flatMap((c) => (typeof c.key === 'string' ? [c.key] : [])));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `--strict-keys` escape hatch: refuse to migrate if any source key would need
|
|
84
|
+
* rewriting, so a customer who requires byte-identical keys can clean up the
|
|
85
|
+
* source first instead of accepting the (reported) rewrites.
|
|
86
|
+
*/
|
|
87
|
+
export class StrictKeysError extends Error {
|
|
88
|
+
rewrites;
|
|
89
|
+
constructor(rewrites) {
|
|
90
|
+
super(`--strict-keys: ${rewrites.length} source key(s) do not conform to Policy A and would be rewritten:\n` +
|
|
91
|
+
rewrites.map((r) => ` "${r.source}" -> "${r.final}" (${r.reasons.join('; ')})`).join('\n') +
|
|
92
|
+
`\nRename the key(s) in the source system and re-run, or drop --strict-keys to accept the rewrites.`);
|
|
93
|
+
this.rewrites = rewrites;
|
|
94
|
+
this.name = 'StrictKeysError';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Plan the rewrites for a run and, when `strict`, throw `StrictKeysError` if any
|
|
99
|
+
* key would be rewritten. Call once at the top of each migrate orchestrator
|
|
100
|
+
* before translating.
|
|
101
|
+
*/
|
|
102
|
+
export function preflightKeyRewrites(changes, opts) {
|
|
103
|
+
planKeyRewritesForChanges(changes);
|
|
104
|
+
if (opts?.strict) {
|
|
105
|
+
const rewrites = getKeyRewrites();
|
|
106
|
+
if (rewrites.length > 0)
|
|
107
|
+
throw new StrictKeysError(rewrites);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -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,88 @@
|
|
|
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
|
+
// 1. Path separators -> dot. Preserves the dotted-hierarchy intent (a `/` or
|
|
39
|
+
// `\` in a source key becomes a namespace dot, matching the historical
|
|
40
|
+
// normalizeKey) rather than a lossy dash.
|
|
41
|
+
if (/[/\\]/.test(k)) {
|
|
42
|
+
k = k.replaceAll(/[/\\]+/g, '.');
|
|
43
|
+
reasons.push('replaced path separators (/ or \\) with "."');
|
|
44
|
+
}
|
|
45
|
+
// 2. Any remaining char outside the Policy A charset -> single dash.
|
|
46
|
+
if (/[^A-Za-z0-9._-]/.test(k)) {
|
|
47
|
+
k = k.replaceAll(/[^A-Za-z0-9._-]+/g, '-');
|
|
48
|
+
reasons.push('replaced disallowed characters with "-"');
|
|
49
|
+
}
|
|
50
|
+
// 3. Trim leading/trailing separators. A leading dot is silently skipped by
|
|
51
|
+
// the loader/verify; a trailing dot or space is stripped by Windows. A
|
|
52
|
+
// leading/trailing dash is merely cosmetic but trimmed for tidiness.
|
|
53
|
+
const trimmed = k.replaceAll(/^[.-]+/g, '').replaceAll(/[.-]+$/g, '');
|
|
54
|
+
if (trimmed !== k) {
|
|
55
|
+
k = trimmed;
|
|
56
|
+
reasons.push('trimmed leading/trailing separators');
|
|
57
|
+
}
|
|
58
|
+
// 4. Windows reserved device name on the FIRST dot-segment (the segment the
|
|
59
|
+
// floor checks) -> suffix "_" so `con` -> `con_`, `nul.foo` -> `nul_.foo`.
|
|
60
|
+
const segs = k.split('.');
|
|
61
|
+
if (segs.length > 0 && RESERVED_DEVICE_RE.test(segs[0])) {
|
|
62
|
+
segs[0] = `${segs[0]}_`;
|
|
63
|
+
k = segs.join('.');
|
|
64
|
+
reasons.push('escaped Windows reserved device name');
|
|
65
|
+
}
|
|
66
|
+
// 5. The reserved whole-key "new".
|
|
67
|
+
if (k === 'new') {
|
|
68
|
+
k = 'new-key';
|
|
69
|
+
reasons.push('escaped reserved key "new"');
|
|
70
|
+
}
|
|
71
|
+
// 6. Empty after sanitizing (e.g. the source key was all disallowed chars).
|
|
72
|
+
if (k.length === 0) {
|
|
73
|
+
k = `key-${shortHash(raw)}`;
|
|
74
|
+
reasons.push('key was empty after sanitizing');
|
|
75
|
+
}
|
|
76
|
+
// 7. Length cap 200 (ASCII-only == byte length). Stable hash suffix keeps two
|
|
77
|
+
// long keys sharing a 200-char prefix from colliding.
|
|
78
|
+
if (k.length > MAX_KEY_LEN) {
|
|
79
|
+
k = `${k.slice(0, MAX_KEY_LEN - 9)}-${shortHash(raw)}`;
|
|
80
|
+
reasons.push(`truncated to ${MAX_KEY_LEN} characters`);
|
|
81
|
+
}
|
|
82
|
+
return { changed: k !== raw, key: k, reasons };
|
|
83
|
+
}
|
|
84
|
+
/** True when a key already satisfies Policy A (no sanitizing needed). */
|
|
85
|
+
export function isPolicyAConformant(key) {
|
|
86
|
+
return !sanitizePolicyAKey(key).changed;
|
|
87
|
+
}
|
|
88
|
+
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
3
|
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
4
|
+
import { 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,10 @@ 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.
|
|
188
|
+
preflightKeyRewrites(opts.changes, { strict: opts.strictKeys });
|
|
184
189
|
if (opts.fullHistory && !opts.source.getCommitMeta) {
|
|
185
190
|
throw new Error(`--full-summary requires the source to provide per-change author + date + summary, but \`${opts.source.name}\` does not. ` +
|
|
186
191
|
`Drop --full-summary or use a source (e.g. launch) that supports it.`);
|
|
@@ -207,6 +212,7 @@ export const applyLocalMigration = async (opts) => {
|
|
|
207
212
|
const reportData = {
|
|
208
213
|
...opts.reportData,
|
|
209
214
|
counts,
|
|
215
|
+
keyRewrites: getKeyRewrites(),
|
|
210
216
|
...(environmentMap && environmentMap.length > 0 ? { environmentMap } : {}),
|
|
211
217
|
followUp: deriveFollowUpFromConversionNotes(opts.reportData.followUp, conversionNotes ?? undefined),
|
|
212
218
|
...(coercedSentinels ? { coercedSentinels } : {}),
|
|
@@ -346,6 +352,7 @@ export const buildAuditFinalCommit = (opts) => ({
|
|
|
346
352
|
const reportData = {
|
|
347
353
|
...opts.reportData,
|
|
348
354
|
counts,
|
|
355
|
+
keyRewrites: getKeyRewrites(),
|
|
349
356
|
...(environmentMap && environmentMap.length > 0 ? { environmentMap } : {}),
|
|
350
357
|
followUp: deriveFollowUpFromConversionNotes(opts.reportData.followUp, conversionNotes ?? undefined),
|
|
351
358
|
...(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,21 @@ 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
|
+
}
|
|
584
616
|
return filePath;
|
|
585
617
|
};
|
|
@@ -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;
|
|
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { removeQfFromGitignore, writeImportState } from './import-state.js';
|
|
4
4
|
import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles, } from './local-write.js';
|
|
5
|
+
import { 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,10 @@ 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.
|
|
62
|
+
preflightKeyRewrites(opts.changes, { strict: opts.strictKeys });
|
|
58
63
|
const acc = {
|
|
59
64
|
coercedSentinels: null,
|
|
60
65
|
droppedOverrides: null,
|
|
@@ -104,6 +109,7 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
104
109
|
const reportData = {
|
|
105
110
|
...opts.reportData,
|
|
106
111
|
counts,
|
|
112
|
+
keyRewrites: getKeyRewrites(),
|
|
107
113
|
...(environmentMap && environmentMap.length > 0 ? { environmentMap } : {}),
|
|
108
114
|
followUp: deriveFollowUpFromConversionNotes(opts.reportData.followUp, conversionNotes ?? undefined),
|
|
109
115
|
...(acc.coercedSentinels ? { coercedSentinels: acc.coercedSentinels } : {}),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* verbs do the LaunchDarkly collapse; a second provider with a different
|
|
9
9
|
* topology composes them differently.
|
|
10
10
|
*/
|
|
11
|
+
import { resolveKey } from '../key-rewriter.js';
|
|
11
12
|
import { mapLaunchDarklyOperator } from './operators.js';
|
|
12
13
|
import { toQuonfigValue } from './values.js';
|
|
13
14
|
const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
@@ -48,7 +49,10 @@ function valueToMatchFor(operator, clause, ctx) {
|
|
|
48
49
|
if (clause.values.length > 1) {
|
|
49
50
|
ctx.report.add('skipped-rule', ctx.sourceKey, `segmentMatch clause listed ${clause.values.length} segments; Quonfig IN_SEG matches one — only "${String(clause.values[0])}" kept, the rest dropped`);
|
|
50
51
|
}
|
|
51
|
-
|
|
52
|
+
// qfg-6na9.3: this value is a referenced segment KEY — resolve it through
|
|
53
|
+
// the per-run rewriter so a sanitized/disambiguated segment key stays in
|
|
54
|
+
// sync with the IN_SEG rules pointing at it (no dangling targeting).
|
|
55
|
+
return { type: 'string', value: resolveKey(String(clause.values[0] ?? '')) };
|
|
52
56
|
}
|
|
53
57
|
if (operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') {
|
|
54
58
|
if (clause.values.length > 1) {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* mutable state. Report accumulation flows through the `ConversionReport`
|
|
16
16
|
* argument supplied by the caller (the `flagsmith.ts` source).
|
|
17
17
|
*/
|
|
18
|
+
import { resolveKey } from '../../key-rewriter.js';
|
|
18
19
|
import { inferValueType, toQuonfigValue } from '../../quonfig-target/values.js';
|
|
19
20
|
const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
20
21
|
/** Flagsmith identity-override rule keys context attributes under this propertyName. */
|
|
@@ -33,8 +34,12 @@ export function flagOutputPath(name) {
|
|
|
33
34
|
export function segmentOutputPath(name) {
|
|
34
35
|
return `segments/${normalizeKey(name)}.json`;
|
|
35
36
|
}
|
|
37
|
+
// qfg-6na9.3: resolve every key through the per-run key rewriter so imported
|
|
38
|
+
// keys 100% conform to Policy A (charset + FS-floor). Used for the segment/flag
|
|
39
|
+
// key field AND the output path; the matching IN_SEG reference below resolves
|
|
40
|
+
// the same source string, so a rewritten segment key never dangles its rules.
|
|
36
41
|
function normalizeKey(key) {
|
|
37
|
-
return key
|
|
42
|
+
return resolveKey(key);
|
|
38
43
|
}
|
|
39
44
|
/** Env name → Quonfig env id slug (matches the source-module slugifier). */
|
|
40
45
|
function slugifyEnvName(name) {
|
|
@@ -662,7 +667,10 @@ function buildSegmentOverrideRule(segov, segmentName, bundle, ctx) {
|
|
|
662
667
|
criteria: [
|
|
663
668
|
{
|
|
664
669
|
operator: 'IN_SEG',
|
|
665
|
-
|
|
670
|
+
// qfg-6na9.3: resolve the referenced segment key through the same
|
|
671
|
+
// rewriter as the segment's own key def, so a sanitized/disambiguated
|
|
672
|
+
// segment key stays in sync here (no dangling targeting).
|
|
673
|
+
valueToMatch: { type: 'string', value: resolveKey(segmentName) },
|
|
666
674
|
},
|
|
667
675
|
],
|
|
668
676
|
value,
|
|
@@ -12,10 +12,12 @@ export declare function slugify(value: string): string;
|
|
|
12
12
|
export declare function isLegacyLogLevel(type: string): boolean;
|
|
13
13
|
export declare function normalizeLogLevelKey(key: string): string;
|
|
14
14
|
/**
|
|
15
|
-
* qfg-qhk1:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* qfg-qhk1 + qfg-6na9.3: resolve keys through the per-run key rewriter so the
|
|
16
|
+
* destination is always flat AND 100% Policy-A conformant (charset + FS-floor,
|
|
17
|
+
* with collision disambiguation). Launch keys are dotted identifiers, so this is
|
|
18
|
+
* a no-op in the common case; an odd key still gets made safe, and the matching
|
|
19
|
+
* IN_SEG reference (resolveSegmentRef, below) resolves the same source string so
|
|
20
|
+
* a rewritten segment key never dangles its rules.
|
|
19
21
|
*/
|
|
20
22
|
export declare function normalizeKey(key: string): string;
|
|
21
23
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { zodToJsonSchema } from './zod-to-json-schema.js';
|
|
2
|
+
import { resolveKey } from '../../key-rewriter.js';
|
|
2
3
|
/**
|
|
3
4
|
* Thrown by transformConfig when the source config is structurally invalid
|
|
4
5
|
* (e.g. variant/valueType mismatch) and the migrator cannot produce a valid
|
|
@@ -24,13 +25,29 @@ export function normalizeLogLevelKey(key) {
|
|
|
24
25
|
return key.startsWith('log-level.') ? key : `log-level.${key}`;
|
|
25
26
|
}
|
|
26
27
|
/**
|
|
27
|
-
* qfg-qhk1:
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* qfg-qhk1 + qfg-6na9.3: resolve keys through the per-run key rewriter so the
|
|
29
|
+
* destination is always flat AND 100% Policy-A conformant (charset + FS-floor,
|
|
30
|
+
* with collision disambiguation). Launch keys are dotted identifiers, so this is
|
|
31
|
+
* a no-op in the common case; an odd key still gets made safe, and the matching
|
|
32
|
+
* IN_SEG reference (resolveSegmentRef, below) resolves the same source string so
|
|
33
|
+
* a rewritten segment key never dangles its rules.
|
|
31
34
|
*/
|
|
32
35
|
export function normalizeKey(key) {
|
|
33
|
-
return key
|
|
36
|
+
return resolveKey(key);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* qfg-6na9.3: an IN_SEG/NOT_IN_SEG criterion's `valueToMatch` holds a referenced
|
|
40
|
+
* segment KEY (not an arbitrary value) — resolve it through the same rewriter as
|
|
41
|
+
* the segment's own key definition so the two stay in sync.
|
|
42
|
+
*/
|
|
43
|
+
function resolveSegmentRef(valueToMatch) {
|
|
44
|
+
if (!valueToMatch || typeof valueToMatch !== 'object')
|
|
45
|
+
return;
|
|
46
|
+
const v = valueToMatch;
|
|
47
|
+
if (typeof v.value === 'string')
|
|
48
|
+
v.value = resolveKey(v.value);
|
|
49
|
+
else if (Array.isArray(v.value))
|
|
50
|
+
v.value = v.value.map((x) => (typeof x === 'string' ? resolveKey(x) : x));
|
|
34
51
|
}
|
|
35
52
|
/**
|
|
36
53
|
* qfg-l18w / qfg-0q1f: Operators Quonfig accepts in rule criteria. Mirrors
|
|
@@ -202,6 +219,9 @@ function normalizeRules(rules, configKey, path) {
|
|
|
202
219
|
const c = crit;
|
|
203
220
|
if (c.valueToMatch) {
|
|
204
221
|
normalizeLaunchValue(c.valueToMatch, configKey, `${path}[${i}].criteria[${j}].valueToMatch`);
|
|
222
|
+
if (c.operator === 'IN_SEG' || c.operator === 'NOT_IN_SEG') {
|
|
223
|
+
resolveSegmentRef(c.valueToMatch);
|
|
224
|
+
}
|
|
205
225
|
}
|
|
206
226
|
}
|
|
207
227
|
}
|
|
@@ -8,16 +8,19 @@
|
|
|
8
8
|
* variants, per-environment state → `environments[]`, the gap dispositions of
|
|
9
9
|
* plan §5.4.
|
|
10
10
|
*/
|
|
11
|
+
import { resolveKey } from '../../key-rewriter.js';
|
|
11
12
|
import { clauseToCriterion, collapseEnvironment, } from '../../quonfig-target/ruleset.js';
|
|
12
13
|
import { inferFlagValueType, toQuonfigValue } from '../../quonfig-target/values.js';
|
|
13
14
|
const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* qfg-6na9.3: resolve keys through the per-run key rewriter for Policy A
|
|
17
|
+
* conformance. LD keys are already `[A-Za-z0-9._-]`, so this is a strict no-op
|
|
18
|
+
* in practice — wired for uniformity + future-proofing (an odd imported key
|
|
19
|
+
* would still be made safe, and stay in sync with any IN_SEG reference to it,
|
|
20
|
+
* which resolves through `ruleset.ts`).
|
|
18
21
|
*/
|
|
19
22
|
function normalizeKey(key) {
|
|
20
|
-
return key
|
|
23
|
+
return resolveKey(key);
|
|
21
24
|
}
|
|
22
25
|
/** Environment keys are LD slugs already; lowercase + hyphenate to be safe. */
|
|
23
26
|
function slugifyEnvKey(key) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy A key rule, HARD-enforced at `qfg create` time client-side (qfg-6na9.2)
|
|
3
|
+
* so a bad key fails fast before the network round-trip. This is a deliberate,
|
|
4
|
+
* hand-kept mirror of app-quonfig `src/lib/domain/config-schemas.ts`
|
|
5
|
+
* `PolicyAKeySchema` — the two MUST move together (see
|
|
6
|
+
* project/plans/26-06-tighter-naming.md, "Decision: Policy A"):
|
|
7
|
+
*
|
|
8
|
+
* - charset `^[A-Za-z0-9._-]+$` (uppercase IS allowed; case-insensitive
|
|
9
|
+
* collision is a separate verify/create-handler check, not here),
|
|
10
|
+
* - 1..200 chars (ASCII-only, so char length == byte length),
|
|
11
|
+
* - not the reserved key `"new"`,
|
|
12
|
+
* - no leading dot (a `.beta.json` config is silently skipped by the loader
|
|
13
|
+
* and `qfg verify`).
|
|
14
|
+
*
|
|
15
|
+
* NOTE the layering: this is the create boundary (hard). The general charset is
|
|
16
|
+
* a *warning* in `qfg verify` (`src/verify/validate.ts`, qfg-6na9.5) during the
|
|
17
|
+
* warn->error soak, and the FS-safety floor there is already hard (qfg-6na9.4).
|
|
18
|
+
* Keep the messages aligned with app-quonfig's for a consistent UX.
|
|
19
|
+
*/
|
|
20
|
+
export declare const POLICY_A_KEY_RE: RegExp;
|
|
21
|
+
/**
|
|
22
|
+
* Returns a human-readable error message for a key that violates Policy A, or
|
|
23
|
+
* `null` if the key is valid. First failing rule wins (matches the order Zod
|
|
24
|
+
* evaluates the refinements in app-quonfig).
|
|
25
|
+
*/
|
|
26
|
+
export declare function policyAKeyError(key: string): string | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy A key rule, HARD-enforced at `qfg create` time client-side (qfg-6na9.2)
|
|
3
|
+
* so a bad key fails fast before the network round-trip. This is a deliberate,
|
|
4
|
+
* hand-kept mirror of app-quonfig `src/lib/domain/config-schemas.ts`
|
|
5
|
+
* `PolicyAKeySchema` — the two MUST move together (see
|
|
6
|
+
* project/plans/26-06-tighter-naming.md, "Decision: Policy A"):
|
|
7
|
+
*
|
|
8
|
+
* - charset `^[A-Za-z0-9._-]+$` (uppercase IS allowed; case-insensitive
|
|
9
|
+
* collision is a separate verify/create-handler check, not here),
|
|
10
|
+
* - 1..200 chars (ASCII-only, so char length == byte length),
|
|
11
|
+
* - not the reserved key `"new"`,
|
|
12
|
+
* - no leading dot (a `.beta.json` config is silently skipped by the loader
|
|
13
|
+
* and `qfg verify`).
|
|
14
|
+
*
|
|
15
|
+
* NOTE the layering: this is the create boundary (hard). The general charset is
|
|
16
|
+
* a *warning* in `qfg verify` (`src/verify/validate.ts`, qfg-6na9.5) during the
|
|
17
|
+
* warn->error soak, and the FS-safety floor there is already hard (qfg-6na9.4).
|
|
18
|
+
* Keep the messages aligned with app-quonfig's for a consistent UX.
|
|
19
|
+
*/
|
|
20
|
+
// Kept as the explicit char class (not the `[\w.-]` the linter suggests) so it
|
|
21
|
+
// is a byte-for-byte mirror of app-quonfig's PolicyAKeySchema and greppable
|
|
22
|
+
// across both sources of truth — see the module doc above.
|
|
23
|
+
// eslint-disable-next-line unicorn/better-regex
|
|
24
|
+
export const POLICY_A_KEY_RE = /^[A-Za-z0-9._-]+$/;
|
|
25
|
+
/**
|
|
26
|
+
* Returns a human-readable error message for a key that violates Policy A, or
|
|
27
|
+
* `null` if the key is valid. First failing rule wins (matches the order Zod
|
|
28
|
+
* evaluates the refinements in app-quonfig).
|
|
29
|
+
*/
|
|
30
|
+
export function policyAKeyError(key) {
|
|
31
|
+
if (key.length === 0)
|
|
32
|
+
return 'Key is required';
|
|
33
|
+
if (key.length > 200)
|
|
34
|
+
return 'Key must be 200 characters or fewer';
|
|
35
|
+
if (key === 'new')
|
|
36
|
+
return 'Key cannot be "new"';
|
|
37
|
+
if (!POLICY_A_KEY_RE.test(key))
|
|
38
|
+
return 'Key can only contain letters, numbers, dots, dashes, and underscores';
|
|
39
|
+
if (key.startsWith('.'))
|
|
40
|
+
return 'Key cannot start with a dot';
|
|
41
|
+
return null;
|
|
42
|
+
}
|
package/dist/verify/validate.js
CHANGED
|
@@ -827,6 +827,7 @@ const FS_SAFETY_FLOOR_CHECKS = [
|
|
|
827
827
|
// pre-filtered by the directory walk before they'd reach the file-map path) can
|
|
828
828
|
// be unit-tested directly.
|
|
829
829
|
export function validateKey(key, file, issues) {
|
|
830
|
+
const errorsBefore = issues.length;
|
|
830
831
|
if (key.length === 0) {
|
|
831
832
|
issues.push({ file, message: `Key is empty`, severity: 'error' });
|
|
832
833
|
}
|
|
@@ -847,6 +848,23 @@ export function validateKey(key, file, issues) {
|
|
|
847
848
|
issues.push({ file, message: check.message, severity: 'error' });
|
|
848
849
|
}
|
|
849
850
|
}
|
|
851
|
+
// qfg-6na9.5: the general Policy A charset as a WARNING — the warn stage of the
|
|
852
|
+
// warn->error soak (project/plans/26-06-tighter-naming.md). A config that trips
|
|
853
|
+
// this must STILL push, so severity is 'warning' (does not set result.valid =
|
|
854
|
+
// false); qfg-6na9.6 will flip it to a hard error after a clean soak. The
|
|
855
|
+
// charset `^[A-Za-z0-9._-]+$` is kept in sync with app-quonfig's
|
|
856
|
+
// PolicyAKeySchema (hard at create). Skipped when this key already earned a
|
|
857
|
+
// hard error above (e.g. a Windows-reserved char is both a floor error AND a
|
|
858
|
+
// charset miss) so we don't double-report the same key.
|
|
859
|
+
// eslint-disable-next-line unicorn/better-regex -- keep the explicit charset in sync with app-quonfig
|
|
860
|
+
if (issues.length === errorsBefore && !/^[A-Za-z0-9._-]+$/.test(key)) {
|
|
861
|
+
issues.push({
|
|
862
|
+
file,
|
|
863
|
+
message: `Key "${key}" has characters outside the allowed set (letters, numbers, ".", "-", "_"). This will become a hard error in a future release — rename it to conform.`,
|
|
864
|
+
severity: 'warning',
|
|
865
|
+
suggestion: `Rename to use only letters, numbers, ".", "-", and "_"`,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
850
868
|
}
|
|
851
869
|
// Case-insensitive duplicate-key detection (qfg-6na9.4,
|
|
852
870
|
// project/plans/26-06-tighter-naming.md "Case-insensitive uniqueness").
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
declare const _default: "0.0.
|
|
1
|
+
declare const _default: "0.0.66";
|
|
2
2
|
export default _default;
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// AUTO-GENERATED from package.json by scripts/generate-version.mjs — do not edit.
|
|
2
|
-
export default '0.0.
|
|
2
|
+
export default '0.0.66';
|