@quonfig/cli 0.0.68 → 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.
- 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/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/migration-report.js +1 -0
- package/dist/migrate/push-to-cloud.js +24 -12
- package/dist/migrate/quonfig-target/report.d.ts +7 -1
- package/dist/migrate/quonfig-target/ruleset.js +15 -3
- package/dist/migrate/quonfig-target/weights.d.ts +20 -0
- package/dist/migrate/quonfig-target/weights.js +48 -0
- package/dist/migrate/source.d.ts +9 -0
- package/dist/migrate/sources/flagsmith/translate.js +9 -0
- package/dist/migrate/sources/launch/translate.d.ts +1 -1
- package/dist/migrate/sources/launch/translate.js +133 -2
- package/dist/migrate/sources/launch.js +10 -0
- package/dist/migrate/sources/launchdarkly/translate.js +12 -2
- package/dist/migrate/sources/launchdarkly.js +15 -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 +111 -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
|
@@ -198,6 +198,7 @@ const CONVERSION_NOTE_HEADINGS = {
|
|
|
198
198
|
'identity-override-as-rule': 'Identity overrides converted to leading rules',
|
|
199
199
|
'identity-traits-referenced': 'Identity traits referenced by segment rules',
|
|
200
200
|
'individual-target-as-rule': 'Individual targets converted to rules',
|
|
201
|
+
'normalized-rollout-weights': 'Normalized rollout weights',
|
|
201
202
|
'skipped-rule': 'Skipped rules (unsupported operators)',
|
|
202
203
|
'unexportable-segment-membership': 'Unexportable segment membership',
|
|
203
204
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { readKeyPlan, removeQfFromGitignore, writeImportState, writeKeyPlan } from './import-state.js';
|
|
4
|
-
import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, writeQuonfigFiles, } from './local-write.js';
|
|
4
|
+
import { buildAuditFinalCommit, buildAuditPerChangeCommits, buildMigrationCommitMessage, buildMigrationCounts, collectExistingWorkspaceKeys, writeQuonfigFiles, } from './local-write.js';
|
|
5
5
|
import { getFullKeyPlan, getKeyRewrites, preflightKeyRewrites } from './key-rewriter.js';
|
|
6
6
|
import { deriveFollowUpFromConversionNotes, writeMigrationReport } from './migration-report.js';
|
|
7
7
|
import { cloneAndStackPush, } from '../util/clone-and-stack-push.js';
|
|
@@ -60,10 +60,14 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
60
60
|
// not per-commit) so key-def and reference sites resolve identically, then
|
|
61
61
|
// enforce --strict-keys before any clone/commit/push. Mappings persisted by a
|
|
62
62
|
// previous run (.qf/key-plan.json in the reused clone) are authoritative so a
|
|
63
|
-
// delta run cannot replan a subset and land on different finals.
|
|
64
|
-
//
|
|
65
|
-
//
|
|
63
|
+
// delta run cannot replan a subset and land on different finals. qfg-hbuy.12:
|
|
64
|
+
// keys already in the workspace seed the rewriter so a case-variant collision
|
|
65
|
+
// (incoming `Foo`, existing `foo`) renames instead of aborting, while a
|
|
66
|
+
// byte-equal match keeps the silent-overwrite re-migration path. (When the
|
|
67
|
+
// clone is fresh, both the plan and the existing keys only come down WITH the
|
|
68
|
+
// clone — the post-clone re-plan below repeats this once it can read them.)
|
|
66
69
|
preflightKeyRewrites(opts.changes, {
|
|
70
|
+
existingKeys: collectExistingWorkspaceKeys(opts.localDir),
|
|
67
71
|
persistedKeys: readKeyPlan(opts.localDir) ?? undefined,
|
|
68
72
|
strict: opts.strictKeys,
|
|
69
73
|
});
|
|
@@ -79,8 +83,23 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
79
83
|
mergeEnvironmentsIntoQuonfigJson(dir, opts.environments);
|
|
80
84
|
}
|
|
81
85
|
};
|
|
86
|
+
// Re-plan once the clone exists: a FRESH clone of a live workspace delivers
|
|
87
|
+
// .qf/key-plan.json AND the existing key files only at this point (the
|
|
88
|
+
// pre-clone preflight above couldn't see either), and both must win before
|
|
89
|
+
// any file is written. The collapsed path runs this at the top of its single
|
|
90
|
+
// commit; audit-log mode prepends it as a no-op commit spec (its apply()
|
|
91
|
+
// stages nothing, so stackCommits silently skips the commit) so it runs
|
|
92
|
+
// BEFORE the first per-change commit writes files (qfg-hbuy.12).
|
|
93
|
+
const replanAgainstClone = (dir) => {
|
|
94
|
+
preflightKeyRewrites(opts.changes, {
|
|
95
|
+
existingKeys: collectExistingWorkspaceKeys(dir),
|
|
96
|
+
persistedKeys: readKeyPlan(dir) ?? undefined,
|
|
97
|
+
strict: opts.strictKeys,
|
|
98
|
+
});
|
|
99
|
+
};
|
|
82
100
|
const commits = opts.fullHistory
|
|
83
101
|
? [
|
|
102
|
+
{ apply: replanAgainstClone, message: 'migrator: replan keys against the cloned workspace (no-op)' },
|
|
84
103
|
...buildAuditPerChangeCommits(opts.changes, opts.source),
|
|
85
104
|
buildAuditFinalCommit({
|
|
86
105
|
changes: opts.changes,
|
|
@@ -97,14 +116,7 @@ export const pushMigrationToCloud = async (opts) => {
|
|
|
97
116
|
{
|
|
98
117
|
message: () => computedCommitMessage,
|
|
99
118
|
async apply(dir) {
|
|
100
|
-
|
|
101
|
-
// migrated workspace delivers .qf/key-plan.json only at this point
|
|
102
|
-
// (the pre-clone preflight above couldn't see it), and the
|
|
103
|
-
// persisted mappings must win before any file is written.
|
|
104
|
-
preflightKeyRewrites(opts.changes, {
|
|
105
|
-
persistedKeys: readKeyPlan(dir) ?? undefined,
|
|
106
|
-
strict: opts.strictKeys,
|
|
107
|
-
});
|
|
119
|
+
replanAgainstClone(dir);
|
|
108
120
|
mergeEnvsIfPresent(dir);
|
|
109
121
|
const { livePaths, resolutions: resolutionEntries } = writeQuonfigFiles(dir, opts.changes, opts.source);
|
|
110
122
|
removeQfFromGitignore(dir);
|
|
@@ -60,7 +60,13 @@ export type ConversionNoteCategory =
|
|
|
60
60
|
* (plan §5.5 / D-F6). Pure FYI — your SDK callers will need to send this attribute at
|
|
61
61
|
* eval time. The `key` is the trait name; `detail` lists the segments referencing it.
|
|
62
62
|
*/
|
|
63
|
-
| 'identity-traits-referenced'
|
|
63
|
+
| 'identity-traits-referenced'
|
|
64
|
+
/**
|
|
65
|
+
* Imported rollout weights violated the weight predicate (neither an even
|
|
66
|
+
* split nor a percent-scale sum of 100000) and were normalized to sum
|
|
67
|
+
* 100000 — or, at zero total, replaced with an even split (qfg-wis6.11).
|
|
68
|
+
*/
|
|
69
|
+
| 'normalized-rollout-weights';
|
|
64
70
|
export interface ConversionNote {
|
|
65
71
|
category: ConversionNoteCategory;
|
|
66
72
|
/** Human-readable detail — names the exact thing lost and why. */
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { resolveKey } from '../key-rewriter.js';
|
|
12
12
|
import { mapLaunchDarklyOperator } from './operators.js';
|
|
13
13
|
import { toQuonfigValue } from './values.js';
|
|
14
|
+
import { normalizeImportedWeights } from './weights.js';
|
|
14
15
|
const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
15
16
|
/** Operators whose `valueToMatch` is a `string_list` of every clause value. */
|
|
16
17
|
const STRING_LIST_OPERATORS = new Set([
|
|
@@ -52,7 +53,10 @@ function valueToMatchFor(operator, clause, ctx) {
|
|
|
52
53
|
// qfg-6na9.3: this value is a referenced segment KEY — resolve it through
|
|
53
54
|
// the per-run rewriter so a sanitized/disambiguated segment key stays in
|
|
54
55
|
// sync with the IN_SEG rules pointing at it (no dangling targeting).
|
|
55
|
-
|
|
56
|
+
// qfg-hbuy.10: resolve in the SEGMENT namespace, so a segment renamed to
|
|
57
|
+
// dodge a same-keyed flag keeps its references (falls back to the default
|
|
58
|
+
// namespace for non-colliding segments).
|
|
59
|
+
return { type: 'string', value: resolveKey(String(clause.values[0] ?? ''), 'segment') };
|
|
56
60
|
}
|
|
57
61
|
if (operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') {
|
|
58
62
|
if (clause.values.length > 1) {
|
|
@@ -130,13 +134,21 @@ export function rolloutToWeightedValues(rollout, ctx) {
|
|
|
130
134
|
? normalizeAttribute(rollout.contextKind, rollout.bucketBy)
|
|
131
135
|
: `${rollout.contextKind && rollout.contextKind.length > 0 ? rollout.contextKind : 'user'}.key`;
|
|
132
136
|
ctx.report.add('rebucketed-rollout', ctx.sourceKey, `percentage rollout hashed on "${hashByPropertyName}" — LaunchDarkly and Quonfig bucket differently, so users will be re-bucketed`);
|
|
137
|
+
// LD weights natively sum to 100000, but guard the predicate anyway
|
|
138
|
+
// (qfg-wis6.11) — the hook now errors on non-conforming imports.
|
|
139
|
+
let weights = rollout.variations.map((wv) => wv.weight);
|
|
140
|
+
const normalized = normalizeImportedWeights(weights);
|
|
141
|
+
if (normalized) {
|
|
142
|
+
ctx.report.add('normalized-rollout-weights', ctx.sourceKey, normalized.detail);
|
|
143
|
+
weights = normalized.weights;
|
|
144
|
+
}
|
|
133
145
|
return {
|
|
134
146
|
type: 'weighted_values',
|
|
135
147
|
value: {
|
|
136
148
|
hashByPropertyName,
|
|
137
|
-
weightedValues: rollout.variations.map((wv) => ({
|
|
149
|
+
weightedValues: rollout.variations.map((wv, i) => ({
|
|
138
150
|
value: variationValue(wv.variation, ctx),
|
|
139
|
-
weight:
|
|
151
|
+
weight: weights[i],
|
|
140
152
|
})),
|
|
141
153
|
},
|
|
142
154
|
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared verb: imported rollout-weight normalization (qfg-wis6.11).
|
|
3
|
+
*
|
|
4
|
+
* Stored weighted values must satisfy the weight predicate qfg-verify
|
|
5
|
+
* enforces: either an even split (all weights equal and > 0) or percentages
|
|
6
|
+
* summing to MAX_WEIGHT. All-equal imports are the canonical even-split
|
|
7
|
+
* encoding and pass through verbatim (Launch's 1/1 rollouts are intended
|
|
8
|
+
* data, not a bug). Anything else is scaled to sum exactly MAX_WEIGHT via
|
|
9
|
+
* largest remainder — ties go to the earliest index — and reported. A zero
|
|
10
|
+
* (or negative) total becomes an even split.
|
|
11
|
+
*/
|
|
12
|
+
export declare const MAX_WEIGHT = 100000;
|
|
13
|
+
export interface NormalizedWeights {
|
|
14
|
+
/** Human-readable report line naming the original ratio and the action. */
|
|
15
|
+
detail: string;
|
|
16
|
+
/** The predicate-satisfying replacement weights, same length as input. */
|
|
17
|
+
weights: number[];
|
|
18
|
+
}
|
|
19
|
+
/** Returns null when the weights already satisfy the predicate (import verbatim). */
|
|
20
|
+
export declare function normalizeImportedWeights(weights: number[]): NormalizedWeights | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared verb: imported rollout-weight normalization (qfg-wis6.11).
|
|
3
|
+
*
|
|
4
|
+
* Stored weighted values must satisfy the weight predicate qfg-verify
|
|
5
|
+
* enforces: either an even split (all weights equal and > 0) or percentages
|
|
6
|
+
* summing to MAX_WEIGHT. All-equal imports are the canonical even-split
|
|
7
|
+
* encoding and pass through verbatim (Launch's 1/1 rollouts are intended
|
|
8
|
+
* data, not a bug). Anything else is scaled to sum exactly MAX_WEIGHT via
|
|
9
|
+
* largest remainder — ties go to the earliest index — and reported. A zero
|
|
10
|
+
* (or negative) total becomes an even split.
|
|
11
|
+
*/
|
|
12
|
+
export const MAX_WEIGHT = 100_000;
|
|
13
|
+
/** Returns null when the weights already satisfy the predicate (import verbatim). */
|
|
14
|
+
export function normalizeImportedWeights(weights) {
|
|
15
|
+
if (weights.length === 0)
|
|
16
|
+
return null;
|
|
17
|
+
const clamped = weights.map((w) => (Number.isFinite(w) && w > 0 ? w : 0));
|
|
18
|
+
const total = clamped.reduce((a, b) => a + b, 0);
|
|
19
|
+
const allEqualNonZero = clamped[0] > 0 && clamped.every((w) => w === clamped[0]);
|
|
20
|
+
if (allEqualNonZero && clamped.every((w, i) => w === weights[i]))
|
|
21
|
+
return null;
|
|
22
|
+
if (total <= 0) {
|
|
23
|
+
return {
|
|
24
|
+
weights: weights.map(() => 1),
|
|
25
|
+
detail: `rollout weights [${weights.join(', ')}] have zero total — imported as an even split (all ones)`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (total === MAX_WEIGHT && clamped.every((w, i) => w === weights[i] && Number.isInteger(w)))
|
|
29
|
+
return null;
|
|
30
|
+
// Largest remainder: floor each exact share, then hand the missing units
|
|
31
|
+
// to the largest fractional remainders, ties to the earliest index.
|
|
32
|
+
const exact = clamped.map((w) => (w / total) * MAX_WEIGHT);
|
|
33
|
+
const floors = exact.map((x) => Math.floor(x));
|
|
34
|
+
let missing = MAX_WEIGHT - floors.reduce((a, b) => a + b, 0);
|
|
35
|
+
const order = exact
|
|
36
|
+
.map((x, i) => ({ fraction: x - Math.floor(x), i }))
|
|
37
|
+
.sort((a, b) => b.fraction - a.fraction || a.i - b.i);
|
|
38
|
+
for (const { i } of order) {
|
|
39
|
+
if (missing <= 0)
|
|
40
|
+
break;
|
|
41
|
+
floors[i] += 1;
|
|
42
|
+
missing -= 1;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
weights: floors,
|
|
46
|
+
detail: `rollout weights [${weights.join(', ')}] (sum ${total}) normalized to [${floors.join(', ')}] to sum ${MAX_WEIGHT}`,
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/migrate/source.d.ts
CHANGED
|
@@ -2,6 +2,15 @@ import type { ConversionNote } from './quonfig-target/report.js';
|
|
|
2
2
|
export interface LegacyChange {
|
|
3
3
|
changedAt?: number;
|
|
4
4
|
key?: string;
|
|
5
|
+
/**
|
|
6
|
+
* qfg-hbuy.10: source-side key namespace. LaunchDarkly flags and segments
|
|
7
|
+
* are SEPARATE key namespaces, so a flag and a segment can legitimately
|
|
8
|
+
* share one source key. Sources that model segments as their own namespace
|
|
9
|
+
* tag segment changes `'segment'` so the key rewriter can disambiguate the
|
|
10
|
+
* collision (the flag keeps the clean name; the segment gets a
|
|
11
|
+
* deterministic `-segment` suffix). Absent = the default namespace.
|
|
12
|
+
*/
|
|
13
|
+
keyNamespace?: 'segment';
|
|
5
14
|
raw: unknown;
|
|
6
15
|
source: string;
|
|
7
16
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { resolveKey } from '../../key-rewriter.js';
|
|
19
19
|
import { inferValueType, toQuonfigValue } from '../../quonfig-target/values.js';
|
|
20
|
+
import { normalizeImportedWeights } from '../../quonfig-target/weights.js';
|
|
20
21
|
const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
21
22
|
/** Flagsmith identity-override rule keys context attributes under this propertyName. */
|
|
22
23
|
const IDENTITY_PROPERTY_NAME = 'user.key';
|
|
@@ -465,6 +466,14 @@ function multivariateWeightedValue(mvOptions, perEnvWeights, ctx) {
|
|
|
465
466
|
weight: Math.round(pct * 1000),
|
|
466
467
|
};
|
|
467
468
|
});
|
|
469
|
+
// Flagsmith allocations don't have to sum to 100 — enforce the weight
|
|
470
|
+
// predicate the qfg-verify hook now errors on (qfg-wis6.11).
|
|
471
|
+
const normalized = normalizeImportedWeights(weightedValues.map((wv) => wv.weight));
|
|
472
|
+
if (normalized) {
|
|
473
|
+
ctx.report.add('normalized-rollout-weights', ctx.feature.name, normalized.detail);
|
|
474
|
+
for (const [i, wv] of weightedValues.entries())
|
|
475
|
+
wv.weight = normalized.weights[i];
|
|
476
|
+
}
|
|
468
477
|
return {
|
|
469
478
|
type: 'weighted_values',
|
|
470
479
|
value: {
|
|
@@ -31,7 +31,7 @@ export declare function normalizeKey(key: string): string;
|
|
|
31
31
|
* test in this file enforces parity.
|
|
32
32
|
*/
|
|
33
33
|
export declare const QUONFIG_SUPPORTED_OPERATORS: Set<string>;
|
|
34
|
-
export declare function transformConfig(config: LaunchConfig, envIdMap: Record<string, string>, onDroppedEnv?: (envId: string) => void, onCoercedSentinel?: (envId: string, valueType: string) => void): Record<string, unknown>;
|
|
34
|
+
export declare function transformConfig(config: LaunchConfig, envIdMap: Record<string, string>, onDroppedEnv?: (envId: string) => void, onCoercedSentinel?: (envId: string, valueType: string) => void, onNormalizedWeights?: (detail: string) => void): Record<string, unknown>;
|
|
35
35
|
export declare function getOutputPath(type: string, key: string): string;
|
|
36
36
|
/**
|
|
37
37
|
* Walks the set of written files looking for cross-type collisions (the same
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { normalizeImportedWeights } from '../../quonfig-target/weights.js';
|
|
1
2
|
import { zodToJsonSchema } from './zod-to-json-schema.js';
|
|
2
|
-
import { resolveKey } from '../../key-rewriter.js';
|
|
3
|
+
import { resolveKey, resolveMappedKey } from '../../key-rewriter.js';
|
|
3
4
|
/**
|
|
4
5
|
* Thrown by transformConfig when the source config is structurally invalid
|
|
5
6
|
* (e.g. variant/valueType mismatch) and the migrator cannot produce a valid
|
|
@@ -49,6 +50,74 @@ function resolveSegmentRef(valueToMatch) {
|
|
|
49
50
|
else if (Array.isArray(v.value))
|
|
50
51
|
v.value = v.value.map((x) => (typeof x === 'string' ? resolveKey(x) : x));
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* qfg-hbuy.11: a value's `decryptWith` names the CONFIG KEY holding its
|
|
55
|
+
* AES-GCM key — a by-key reference, exactly like an IN_SEG segment ref. When
|
|
56
|
+
* the referenced config was renamed by the key rewriter the reference must
|
|
57
|
+
* follow, or the config can never decrypt. A key absent from the map points
|
|
58
|
+
* at a pre-existing workspace config the import does not own — left
|
|
59
|
+
* untouched. Recurses into weighted_values so encrypted variant splits are
|
|
60
|
+
* covered too.
|
|
61
|
+
*/
|
|
62
|
+
function rewriteValueKeyRefs(value) {
|
|
63
|
+
if (!value || typeof value !== 'object')
|
|
64
|
+
return;
|
|
65
|
+
const v = value;
|
|
66
|
+
if (typeof v.decryptWith === 'string') {
|
|
67
|
+
const mapped = resolveMappedKey(v.decryptWith);
|
|
68
|
+
if (mapped !== null)
|
|
69
|
+
v.decryptWith = mapped;
|
|
70
|
+
}
|
|
71
|
+
if (v.type === 'weighted_values') {
|
|
72
|
+
const inner = v.value;
|
|
73
|
+
let entries = [];
|
|
74
|
+
if (Array.isArray(inner)) {
|
|
75
|
+
entries = inner;
|
|
76
|
+
}
|
|
77
|
+
else if (inner && typeof inner === 'object') {
|
|
78
|
+
const wvs = inner.weightedValues;
|
|
79
|
+
if (Array.isArray(wvs))
|
|
80
|
+
entries = wvs;
|
|
81
|
+
}
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (entry && typeof entry === 'object' && entry.value) {
|
|
84
|
+
rewriteValueKeyRefs(entry.value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* qfg-hbuy.11: walk every value position that can carry `decryptWith`
|
|
91
|
+
* (default rules, per-environment rules, variants) and rewrite the reference
|
|
92
|
+
* through the final key map. Mirrors normalizeJsonValuesInConfig's traversal.
|
|
93
|
+
*/
|
|
94
|
+
function rewriteKeyReferencesInConfig(out) {
|
|
95
|
+
const walkRules = (rules) => {
|
|
96
|
+
if (!Array.isArray(rules))
|
|
97
|
+
return;
|
|
98
|
+
for (const rule of rules) {
|
|
99
|
+
if (!rule || typeof rule !== 'object')
|
|
100
|
+
continue;
|
|
101
|
+
const r = rule;
|
|
102
|
+
if (r.value)
|
|
103
|
+
rewriteValueKeyRefs(r.value);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const defaultSection = out.default;
|
|
107
|
+
if (defaultSection)
|
|
108
|
+
walkRules(defaultSection.rules);
|
|
109
|
+
if (Array.isArray(out.environments)) {
|
|
110
|
+
for (const env of out.environments) {
|
|
111
|
+
walkRules(env.rules);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(out.variants)) {
|
|
115
|
+
for (const variant of out.variants) {
|
|
116
|
+
if (variant && variant.value)
|
|
117
|
+
rewriteValueKeyRefs(variant.value);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
52
121
|
/**
|
|
53
122
|
* qfg-l18w / qfg-0q1f: Operators Quonfig accepts in rule criteria. Mirrors
|
|
54
123
|
* the `OperatorSchema` enum in cli/src/verify/validate.ts (and app-quonfig's
|
|
@@ -271,13 +340,71 @@ function normalizeJsonValuesInConfig(out) {
|
|
|
271
340
|
}
|
|
272
341
|
}
|
|
273
342
|
}
|
|
274
|
-
|
|
343
|
+
// qfg-wis6.11: Launch's relative weights import verbatim when all weights
|
|
344
|
+
// are equal (the intended even-split encoding, e.g. 1/1). Any other ratio is
|
|
345
|
+
// normalized to sum 100000, and a zero total becomes an even split, so
|
|
346
|
+
// imported data always satisfies the weight predicate the qfg-verify hook
|
|
347
|
+
// errors on. Every change is surfaced through the callback.
|
|
348
|
+
function normalizeWeightsInRules(rules, path, onNormalized) {
|
|
349
|
+
if (!Array.isArray(rules))
|
|
350
|
+
return;
|
|
351
|
+
for (const [i, rule] of rules.entries()) {
|
|
352
|
+
if (!rule || typeof rule !== 'object')
|
|
353
|
+
continue;
|
|
354
|
+
const v = rule.value;
|
|
355
|
+
if (!v || typeof v !== 'object' || v.type !== 'weighted_values')
|
|
356
|
+
continue;
|
|
357
|
+
const inner = v.value;
|
|
358
|
+
let entries = null;
|
|
359
|
+
if (Array.isArray(inner)) {
|
|
360
|
+
entries = inner;
|
|
361
|
+
}
|
|
362
|
+
else if (inner && typeof inner === 'object') {
|
|
363
|
+
const wvs = inner.weightedValues;
|
|
364
|
+
if (Array.isArray(wvs))
|
|
365
|
+
entries = wvs;
|
|
366
|
+
}
|
|
367
|
+
if (!entries || entries.length === 0)
|
|
368
|
+
continue;
|
|
369
|
+
const weights = entries.map((e) => Number(e?.weight ?? 0));
|
|
370
|
+
const normalized = normalizeImportedWeights(weights);
|
|
371
|
+
if (!normalized)
|
|
372
|
+
continue;
|
|
373
|
+
for (const [j, entry] of entries.entries()) {
|
|
374
|
+
if (entry && typeof entry === 'object')
|
|
375
|
+
entry.weight = normalized.weights[j];
|
|
376
|
+
}
|
|
377
|
+
onNormalized?.(`${path}[${i}]: ${normalized.detail}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function normalizeWeightsInConfig(out, onNormalized) {
|
|
381
|
+
const defaultSection = out.default;
|
|
382
|
+
if (defaultSection) {
|
|
383
|
+
normalizeWeightsInRules(defaultSection.rules, 'default.rules', onNormalized);
|
|
384
|
+
}
|
|
385
|
+
if (Array.isArray(out.environments)) {
|
|
386
|
+
for (const [i, env] of out.environments.entries()) {
|
|
387
|
+
const envId = typeof env.id === 'string' ? env.id : String(i);
|
|
388
|
+
normalizeWeightsInRules(env.rules, `environments[${envId}].rules`, onNormalized);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentinel, onNormalizedWeights) {
|
|
275
393
|
const out = JSON.parse(JSON.stringify(config));
|
|
276
394
|
// qfg-qhk1: normalize `/` to `.` in the key so the JSON `key` field matches
|
|
277
395
|
// the flat dotted filename the migrator will write.
|
|
278
396
|
if (typeof out.key === 'string') {
|
|
279
397
|
out.key = normalizeKey(out.key);
|
|
280
398
|
}
|
|
399
|
+
// qfg-hbuy.11: `schemaKey` references ANOTHER config's key (the schema this
|
|
400
|
+
// config validates against). If that schema was renamed by the key rewriter
|
|
401
|
+
// the reference must follow; a schemaKey absent from the map references a
|
|
402
|
+
// pre-existing workspace schema and is left untouched.
|
|
403
|
+
if (typeof out.schemaKey === 'string') {
|
|
404
|
+
const mapped = resolveMappedKey(out.schemaKey);
|
|
405
|
+
if (mapped !== null)
|
|
406
|
+
out.schemaKey = mapped;
|
|
407
|
+
}
|
|
281
408
|
delete out.changedBy;
|
|
282
409
|
delete out.modifiedAt;
|
|
283
410
|
delete out.createdAt;
|
|
@@ -374,6 +501,10 @@ export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentine
|
|
|
374
501
|
}
|
|
375
502
|
}
|
|
376
503
|
normalizeJsonValuesInConfig(out);
|
|
504
|
+
// qfg-hbuy.11: rewrite `decryptWith` references AFTER json-normalization so
|
|
505
|
+
// the walker sees the final value shapes.
|
|
506
|
+
rewriteKeyReferencesInConfig(out);
|
|
507
|
+
normalizeWeightsInConfig(out, onNormalizedWeights);
|
|
377
508
|
validateOperators(out);
|
|
378
509
|
return out;
|
|
379
510
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { ConversionReport } from '../quonfig-target/report.js';
|
|
1
2
|
import { fetchAllChangeHistory, fetchEnvironments } from './launch/api.js';
|
|
2
3
|
import { InvalidSourceConfigError, getOutputPath, isLegacyLogLevel, slugify, transformConfig, } from './launch/translate.js';
|
|
3
4
|
const SOURCE_NAME = 'launch';
|
|
4
5
|
const state = {
|
|
5
6
|
apiKey: null,
|
|
6
7
|
coercedSentinels: new Map(),
|
|
8
|
+
conversionReport: new ConversionReport(),
|
|
7
9
|
droppedOverrides: new Map(),
|
|
8
10
|
envIdMap: null,
|
|
9
11
|
skippedConfigs: [],
|
|
@@ -71,6 +73,8 @@ function translateImpl(change) {
|
|
|
71
73
|
state.coercedSentinels.set(envId, perFlag);
|
|
72
74
|
}
|
|
73
75
|
perFlag.set(outputPath, (perFlag.get(outputPath) ?? 0) + 1);
|
|
76
|
+
}, (detail) => {
|
|
77
|
+
state.conversionReport.add('normalized-rollout-weights', raw.key, detail);
|
|
74
78
|
});
|
|
75
79
|
}
|
|
76
80
|
catch (error) {
|
|
@@ -145,6 +149,10 @@ async function validateAuthImpl(apiKey) {
|
|
|
145
149
|
state.droppedOverrides = new Map();
|
|
146
150
|
state.skippedConfigs = [];
|
|
147
151
|
state.coercedSentinels = new Map();
|
|
152
|
+
state.conversionReport.reset();
|
|
153
|
+
}
|
|
154
|
+
function getConversionNotesImpl() {
|
|
155
|
+
return state.conversionReport.isEmpty() ? null : state.conversionReport.all();
|
|
148
156
|
}
|
|
149
157
|
export const launchSource = {
|
|
150
158
|
fetchChanges(sinceEpochMs, onProgress) {
|
|
@@ -152,6 +160,7 @@ export const launchSource = {
|
|
|
152
160
|
},
|
|
153
161
|
getCoercedSentinels: getCoercedSentinelsImpl,
|
|
154
162
|
getCommitMeta: getCommitMetaImpl,
|
|
163
|
+
getConversionNotes: getConversionNotesImpl,
|
|
155
164
|
getDroppedOverrides: getDroppedOverridesImpl,
|
|
156
165
|
getSkippedConfigs: getSkippedConfigsImpl,
|
|
157
166
|
listEnvironments: listEnvironmentsImpl,
|
|
@@ -165,4 +174,5 @@ export function __resetLaunchSourceForTests() {
|
|
|
165
174
|
state.droppedOverrides = new Map();
|
|
166
175
|
state.skippedConfigs = [];
|
|
167
176
|
state.coercedSentinels = new Map();
|
|
177
|
+
state.conversionReport.reset();
|
|
168
178
|
}
|
|
@@ -22,6 +22,16 @@ const ALWAYS_TRUE = { operator: 'ALWAYS_TRUE' };
|
|
|
22
22
|
function normalizeKey(key) {
|
|
23
23
|
return resolveKey(key);
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* qfg-hbuy.10: LD segments are their OWN key namespace — a project can carry a
|
|
27
|
+
* flag and a segment both keyed `checkout`. Segment key definitions (and the
|
|
28
|
+
* IN_SEG references in `ruleset.ts`) resolve through the segment namespace so
|
|
29
|
+
* the collision case gets the deterministic `-segment` suffix while the flag
|
|
30
|
+
* keeps the clean name. Non-colliding segments resolve exactly as before.
|
|
31
|
+
*/
|
|
32
|
+
function normalizeSegmentKey(key) {
|
|
33
|
+
return resolveKey(key, 'segment');
|
|
34
|
+
}
|
|
25
35
|
/** Environment keys are LD slugs already; lowercase + hyphenate to be safe. */
|
|
26
36
|
function slugifyEnvKey(key) {
|
|
27
37
|
return key
|
|
@@ -53,7 +63,7 @@ export function flagOutputPath(key) {
|
|
|
53
63
|
return `feature-flags/${normalizeKey(key)}.json`;
|
|
54
64
|
}
|
|
55
65
|
export function segmentOutputPath(key) {
|
|
56
|
-
return `segments/${
|
|
66
|
+
return `segments/${normalizeSegmentKey(key)}.json`;
|
|
57
67
|
}
|
|
58
68
|
/**
|
|
59
69
|
* Translate one LaunchDarkly flag into a Quonfig feature-flag file.
|
|
@@ -199,7 +209,7 @@ export function translateSegment(segment, report) {
|
|
|
199
209
|
// Trailing catch-all: anyone not matched above is not in the segment.
|
|
200
210
|
rules.push({ criteria: [ALWAYS_TRUE], value: { type: 'bool', value: false } });
|
|
201
211
|
const out = {
|
|
202
|
-
key:
|
|
212
|
+
key: normalizeSegmentKey(segment.key),
|
|
203
213
|
type: 'segment',
|
|
204
214
|
valueType: 'bool',
|
|
205
215
|
default: { rules },
|
|
@@ -164,7 +164,14 @@ async function* fetchChangesImpl() {
|
|
|
164
164
|
yield { key: flag.key, raw: { data: flag, kind: 'flag' }, source: SOURCE_NAME };
|
|
165
165
|
}
|
|
166
166
|
for (const segment of snapshot.segments) {
|
|
167
|
-
|
|
167
|
+
// qfg-hbuy.10: LD segments are their own key namespace — tag them so the
|
|
168
|
+
// key rewriter can disambiguate a flag and a segment sharing one key.
|
|
169
|
+
yield {
|
|
170
|
+
key: segment.key,
|
|
171
|
+
keyNamespace: 'segment',
|
|
172
|
+
raw: { data: segment, kind: 'segment' },
|
|
173
|
+
source: SOURCE_NAME,
|
|
174
|
+
};
|
|
168
175
|
}
|
|
169
176
|
}
|
|
170
177
|
/** Path to this run's audit-walk resume cursor (always under os.tmpdir; see filename comment). */
|
|
@@ -235,7 +242,13 @@ async function* fetchAuditHistory(apiKey) {
|
|
|
235
242
|
}
|
|
236
243
|
}
|
|
237
244
|
for (const segment of segmentsByKey.values()) {
|
|
238
|
-
|
|
245
|
+
// qfg-hbuy.10: see the matching tag in fetchChangesImpl.
|
|
246
|
+
yield {
|
|
247
|
+
key: segment.key,
|
|
248
|
+
keyNamespace: 'segment',
|
|
249
|
+
raw: { data: segment, kind: 'segment' },
|
|
250
|
+
source: SOURCE_NAME,
|
|
251
|
+
};
|
|
239
252
|
}
|
|
240
253
|
}
|
|
241
254
|
function translateImpl(change) {
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - 1..200 chars (ASCII-only, so char length == byte length),
|
|
11
11
|
* - not the reserved key `"new"`,
|
|
12
12
|
* - no leading dot (a `.beta.json` config is silently skipped by the loader
|
|
13
|
-
* and `qfg verify`)
|
|
13
|
+
* and `qfg verify`),
|
|
14
|
+
* - no trailing dot or space (silently stripped on Windows), and
|
|
15
|
+
* - the first dot-segment is not a Windows reserved device name (qfg-hbuy.6).
|
|
14
16
|
*
|
|
15
17
|
* NOTE the layering: this is the create boundary (hard). The general charset is
|
|
16
18
|
* a *warning* in `qfg verify` (`src/verify/validate.ts`, qfg-6na9.5) during the
|
|
@@ -18,6 +20,18 @@
|
|
|
18
20
|
* Keep the messages aligned with app-quonfig's for a consistent UX.
|
|
19
21
|
*/
|
|
20
22
|
export declare const POLICY_A_KEY_RE: RegExp;
|
|
23
|
+
/**
|
|
24
|
+
* Windows reserved device names (qfg-hbuy.6). Byte-identical mirror of
|
|
25
|
+
* app-quonfig `src/lib/domain/config-schemas.ts` `WINDOWS_RESERVED_DEVICE_NAME_RE`
|
|
26
|
+
* and the qfg-verify hook's FS-safety floor (`src/verify/validate.ts`
|
|
27
|
+
* `FS_SAFETY_FLOOR_CHECKS`). Matched case-insensitively against the FIRST
|
|
28
|
+
* dot-segment of the key (`k.split('.')[0]`), because Windows treats
|
|
29
|
+
* `con.anything` as the `con` device. So `con`, `CON`, and `com3.foo` are
|
|
30
|
+
* rejected while `foo.con`, `com10`, and `console` are fine. The audit
|
|
31
|
+
* (project/plans/26-07-policy-a-audit-findings.md) specifically wants these
|
|
32
|
+
* checks byte-identical across enforcement points — keep the regex in sync.
|
|
33
|
+
*/
|
|
34
|
+
export declare const WINDOWS_RESERVED_DEVICE_NAME_RE: RegExp;
|
|
21
35
|
/**
|
|
22
36
|
* Returns a human-readable error message for a key that violates Policy A, or
|
|
23
37
|
* `null` if the key is valid. First failing rule wins (matches the order Zod
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - 1..200 chars (ASCII-only, so char length == byte length),
|
|
11
11
|
* - not the reserved key `"new"`,
|
|
12
12
|
* - no leading dot (a `.beta.json` config is silently skipped by the loader
|
|
13
|
-
* and `qfg verify`)
|
|
13
|
+
* and `qfg verify`),
|
|
14
|
+
* - no trailing dot or space (silently stripped on Windows), and
|
|
15
|
+
* - the first dot-segment is not a Windows reserved device name (qfg-hbuy.6).
|
|
14
16
|
*
|
|
15
17
|
* NOTE the layering: this is the create boundary (hard). The general charset is
|
|
16
18
|
* a *warning* in `qfg verify` (`src/verify/validate.ts`, qfg-6na9.5) during the
|
|
@@ -22,6 +24,18 @@
|
|
|
22
24
|
// across both sources of truth — see the module doc above.
|
|
23
25
|
// eslint-disable-next-line unicorn/better-regex
|
|
24
26
|
export const POLICY_A_KEY_RE = /^[A-Za-z0-9._-]+$/;
|
|
27
|
+
/**
|
|
28
|
+
* Windows reserved device names (qfg-hbuy.6). Byte-identical mirror of
|
|
29
|
+
* app-quonfig `src/lib/domain/config-schemas.ts` `WINDOWS_RESERVED_DEVICE_NAME_RE`
|
|
30
|
+
* and the qfg-verify hook's FS-safety floor (`src/verify/validate.ts`
|
|
31
|
+
* `FS_SAFETY_FLOOR_CHECKS`). Matched case-insensitively against the FIRST
|
|
32
|
+
* dot-segment of the key (`k.split('.')[0]`), because Windows treats
|
|
33
|
+
* `con.anything` as the `con` device. So `con`, `CON`, and `com3.foo` are
|
|
34
|
+
* rejected while `foo.con`, `com10`, and `console` are fine. The audit
|
|
35
|
+
* (project/plans/26-07-policy-a-audit-findings.md) specifically wants these
|
|
36
|
+
* checks byte-identical across enforcement points — keep the regex in sync.
|
|
37
|
+
*/
|
|
38
|
+
export const WINDOWS_RESERVED_DEVICE_NAME_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
|
|
25
39
|
/**
|
|
26
40
|
* Returns a human-readable error message for a key that violates Policy A, or
|
|
27
41
|
* `null` if the key is valid. First failing rule wins (matches the order Zod
|
|
@@ -38,5 +52,15 @@ export function policyAKeyError(key) {
|
|
|
38
52
|
return 'Key can only contain letters, numbers, dots, dashes, and underscores';
|
|
39
53
|
if (key.startsWith('.'))
|
|
40
54
|
return 'Key cannot start with a dot';
|
|
55
|
+
// ── FS-safety floor parity with app-quonfig PolicyAKeySchema (qfg-hbuy.6) ──
|
|
56
|
+
// The charset above already covers the hook's control-char and
|
|
57
|
+
// Windows-reserved-char floor items; these two are the only floor items the
|
|
58
|
+
// charset alone permits. Without them the UI/create boundary accepts the key
|
|
59
|
+
// and the user gets a confusing 422 at push time instead of a clean error.
|
|
60
|
+
if (/[ .]$/.test(key))
|
|
61
|
+
return 'Key cannot end with a dot or space';
|
|
62
|
+
if (WINDOWS_RESERVED_DEVICE_NAME_RE.test(key.split('.')[0])) {
|
|
63
|
+
return 'Key cannot use a Windows reserved device name (con, prn, aux, nul, com1-9, lpt1-9) before the first dot';
|
|
64
|
+
}
|
|
41
65
|
return null;
|
|
42
66
|
}
|
|
@@ -21,5 +21,12 @@
|
|
|
21
21
|
* exists to catch (verified live on staging: a `configs/bad charset key.json`
|
|
22
22
|
* push was accepted unvalidated). A listed-but-unreadable file is now a hard
|
|
23
23
|
* failure (fail closed), not a silent skip.
|
|
24
|
+
*
|
|
25
|
+
* qfg-hbuy.4: enumerates RECURSIVELY (`-r`) and with NO name filtering. The
|
|
26
|
+
* old non-recursive listing plus `endsWith('.json') && !includes('/.')` filter
|
|
27
|
+
* meant dotfiles, nested paths (configs/sub/x.json), and case-variant
|
|
28
|
+
* extensions (FOO.JSON) never reached validation at all — ghost files that
|
|
29
|
+
* push fine but no loader reads. The hook must see EVERYTHING under the
|
|
30
|
+
* validated dirs; validateFileMap is the layer that decides what is an error.
|
|
24
31
|
*/
|
|
25
32
|
export declare function readFilesFromCommit(commitOid: string, cwd?: string): Map<string, string>;
|