@quonfig/cli 0.0.64 → 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.
@@ -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
- return { type: 'string', value: String(clause.values[0] ?? '') };
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.replaceAll('/', '.');
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
- valueToMatch: { type: 'string', value: segmentName },
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: Quonfig keys cannot contain `/` — keys are stored as flat dotted
16
- * filenames, and a `/` in a source key would create a nested directory tree
17
- * (data-loss-on-tombstone, cross-key collisions). Normalize `/` to `.` so the
18
- * destination is always flat.
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: Quonfig keys cannot contain `/` — keys are stored as flat dotted
28
- * filenames, and a `/` in a source key would create a nested directory tree
29
- * (data-loss-on-tombstone, cross-key collisions). Normalize `/` to `.` so the
30
- * destination is always flat.
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.replaceAll('/', '.');
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
- * LaunchDarkly keys are already flat and URL-safe, but normalize `/` → `.`
16
- * defensively so a key can never create a nested directory tree (matches the
17
- * flat-path contract `local-write.ts` enforces).
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.replaceAll('/', '.');
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
+ }
@@ -9,7 +9,8 @@
9
9
  * - Valid JSON
10
10
  * - Schema compliance (StoredConfigSchema from Zod)
11
11
  * - Key matches filename
12
- * - Key constraints (1-512 chars, no slashes, not "new")
12
+ * - Key constraints (1-200 chars, no slashes, not "new", FS-safety floor)
13
+ * - Case-insensitive key uniqueness (no Foo/foo collisions)
13
14
  * - Config type matches directory
14
15
  * - Segment constraints (valueType=bool, sendToClientSdk=false)
15
16
  * - Log level constraints (valueType=log_level)
@@ -55,4 +56,5 @@ export declare function validateWorkspace(workspaceDir: string): ValidationResul
55
56
  * Used by the pre-receive hook which reads files from git objects.
56
57
  */
57
58
  export declare function validateFileMap(files: Map<string, string>): ValidationResult;
59
+ export declare function validateKey(key: string, file: string, issues: ValidationIssue[]): void;
58
60
  export declare function formatResult(result: ValidationResult): string;