@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.
Files changed (37) hide show
  1. package/README.md +50 -50
  2. package/dist/codegen/code-generators/base-generator.d.ts +8 -1
  3. package/dist/codegen/code-generators/base-generator.js +3 -1
  4. package/dist/codegen/code-generators/base-typescript-generator.d.ts +34 -1
  5. package/dist/codegen/code-generators/base-typescript-generator.js +78 -2
  6. package/dist/codegen/code-generators/node-typescript-generator.js +7 -12
  7. package/dist/codegen/code-generators/react-typescript-generator.js +9 -12
  8. package/dist/commands/generate.js +4 -2
  9. package/dist/init/schema.js +11 -2
  10. package/dist/migrate/import-state.d.ts +3 -2
  11. package/dist/migrate/import-state.js +20 -7
  12. package/dist/migrate/key-rewriter.d.ts +70 -14
  13. package/dist/migrate/key-rewriter.js +186 -38
  14. package/dist/migrate/local-write.d.ts +10 -0
  15. package/dist/migrate/local-write.js +27 -1
  16. package/dist/migrate/migration-report.js +1 -0
  17. package/dist/migrate/push-to-cloud.js +24 -12
  18. package/dist/migrate/quonfig-target/report.d.ts +7 -1
  19. package/dist/migrate/quonfig-target/ruleset.js +15 -3
  20. package/dist/migrate/quonfig-target/weights.d.ts +20 -0
  21. package/dist/migrate/quonfig-target/weights.js +48 -0
  22. package/dist/migrate/source.d.ts +9 -0
  23. package/dist/migrate/sources/flagsmith/translate.js +9 -0
  24. package/dist/migrate/sources/launch/translate.d.ts +1 -1
  25. package/dist/migrate/sources/launch/translate.js +133 -2
  26. package/dist/migrate/sources/launch.js +10 -0
  27. package/dist/migrate/sources/launchdarkly/translate.js +12 -2
  28. package/dist/migrate/sources/launchdarkly.js +15 -2
  29. package/dist/util/policy-a-key.d.ts +15 -1
  30. package/dist/util/policy-a-key.js +25 -1
  31. package/dist/verify/standalone.d.ts +7 -0
  32. package/dist/verify/standalone.js +15 -9
  33. package/dist/verify/validate.js +111 -4
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/oclif.manifest.json +1 -1
  37. 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. (When the
64
- // clone is fresh, the plan only comes down WITH the clone — the collapsed
65
- // commit's apply() re-plans below once it can read the cloned .qf/.)
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
- // Re-plan now that the clone exists: a FRESH clone of a previously
101
- // migrated workspace delivers .qf/key-plan.json only at this point
102
- // (the pre-clone preflight above couldn't see it), and the
103
- // persisted mappings must win before any file is written.
104
- preflightKeyRewrites(opts.changes, {
105
- persistedKeys: readKeyPlan(dir) ?? undefined,
106
- strict: opts.strictKeys,
107
- });
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
- return { type: 'string', value: resolveKey(String(clause.values[0] ?? '')) };
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: wv.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
+ }
@@ -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
- export function transformConfig(config, envIdMap, onDroppedEnv, onCoercedSentinel) {
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/${normalizeKey(key)}.json`;
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: normalizeKey(segment.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
- yield { key: segment.key, raw: { data: segment, kind: 'segment' }, source: SOURCE_NAME };
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
- yield { key: segment.key, raw: { data: segment, kind: 'segment' }, source: SOURCE_NAME };
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>;