@quonfig/cli 0.4.0 → 0.5.0

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 (47) hide show
  1. package/README.md +157 -75
  2. package/dist/agent/skill-bundle.generated.d.ts +1 -1
  3. package/dist/agent/skill-bundle.generated.js +4 -4
  4. package/dist/codegen/code-generators/base-typescript-generator.d.ts +1 -1
  5. package/dist/codegen/schema-extractor.d.ts +1 -1
  6. package/dist/commands/config-schema.js +9 -2
  7. package/dist/commands/create.d.ts +3 -1
  8. package/dist/commands/create.js +93 -20
  9. package/dist/commands/get.d.ts +1 -0
  10. package/dist/commands/get.js +40 -3
  11. package/dist/commands/info.js +10 -1
  12. package/dist/commands/set-default.d.ts +2 -0
  13. package/dist/commands/set-default.js +70 -18
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +35 -3
  16. package/dist/interactive-prompt.d.ts +18 -0
  17. package/dist/interactive-prompt.js +11 -2
  18. package/dist/migrate/quonfig-target/operators.js +4 -2
  19. package/dist/migrate/quonfig-target/ruleset.d.ts +48 -0
  20. package/dist/migrate/quonfig-target/ruleset.js +132 -8
  21. package/dist/migrate/sources/flagsmith/translate.js +11 -0
  22. package/dist/migrate/sources/launchdarkly/translate.js +19 -16
  23. package/dist/push/git-pack.d.ts +12 -0
  24. package/dist/push/git-pack.js +27 -3
  25. package/dist/push/run-push.d.ts +2 -1
  26. package/dist/push/run-push.js +1 -2
  27. package/dist/ui/get-string.d.ts +18 -1
  28. package/dist/ui/get-string.js +20 -5
  29. package/dist/ui/get-value.d.ts +3 -1
  30. package/dist/ui/get-value.js +7 -7
  31. package/dist/util/clone-and-stack-push.js +8 -3
  32. package/dist/util/encryption.js +11 -20
  33. package/dist/util/git-ops.d.ts +4 -2
  34. package/dist/util/git-ops.js +4 -2
  35. package/dist/util/redact.d.ts +56 -0
  36. package/dist/util/redact.js +98 -0
  37. package/dist/util/value-source.d.ts +30 -0
  38. package/dist/util/value-source.js +81 -0
  39. package/dist/verify/regex-dialect.d.ts +39 -0
  40. package/dist/verify/regex-dialect.js +278 -0
  41. package/dist/verify/standalone.d.ts +3 -1
  42. package/dist/verify/standalone.js +4 -19
  43. package/dist/verify/validate.js +20 -0
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/oclif.manifest.json +236 -192
  47. package/package.json +18 -16
@@ -12,7 +12,9 @@ import { makeConfidentialValue } from '../util/encryption.js';
12
12
  import isInteractive from '../util/is-interactive.js';
13
13
  import nameArg from '../util/name-arg.js';
14
14
  import { catchAllRule, countTargetingRules, keptTargetingNote, replacedTargetingNote, seedScopeRules, seededFromDefaultNote, upsertEnvRules, upsertFallbackRule, } from '../util/rules.js';
15
+ import { redactedDisplay, redactedSummary } from '../util/redact.js';
15
16
  import secretFlags, { parsedSecretFlags } from '../util/secret-flags.js';
17
+ import { resolveValueSource, valueSourceFlags } from '../util/value-source.js';
16
18
  export default class SetDefault extends APICommand {
17
19
  static aliases = ['toggle'];
18
20
  static args = { ...nameArg };
@@ -31,6 +33,16 @@ rules (they stay in git history).
31
33
  To set a percentage rollout (gradual rollout / A/B test / canary deploy) instead:
32
34
  qfg set-rollout my.flag --environment production --true-percent 20
33
35
 
36
+ Secrets (--secret, or any config whose value is already encrypted) are
37
+ STRING-ONLY. To store a JSON secret, make it a string secret holding the JSON
38
+ and JSON.parse it in your app. The CLI never prints a secret's plaintext; it
39
+ shows a redacted fingerprint like [encrypted *****3fa9c, 51 chars, ends …a1b2].
40
+ Rotate a secret without the value entering argv / shell history:
41
+ pbpaste | qfg set-default stripe.api-key --environment production --secret --value-stdin --confirm
42
+ qfg set-default stripe.api-key --environment production --secret --value-from-env STRIPE_API_KEY --confirm
43
+ (--env-var is different: it stores NO value and tells the SDK to read one from
44
+ that env var at runtime.)
45
+
34
46
  To see all current values and rules for a flag:
35
47
  qfg info my.flag
36
48
 
@@ -41,7 +53,11 @@ To target specific organizations or users, write rules on their KEY
41
53
  '<%= config.bin %> <%= command.id %> my.flag.name # prompts for value and env',
42
54
  '<%= config.bin %> <%= command.id %> my.flag.name --value=true --environment=staging',
43
55
  '<%= config.bin %> <%= command.id %> my.flag.name --value=false --environment=production --replace-targeting # kill-switch: off for everyone',
44
- '<%= config.bin %> <%= command.id %> my.flag.name --value=true --secret',
56
+ '# Rotate a secret without the value entering argv:',
57
+ 'pbpaste | <%= config.bin %> <%= command.id %> my.secret --environment=production --secret --value-stdin --confirm',
58
+ '<%= config.bin %> <%= command.id %> my.secret --environment=production --secret --value-from-env=MY_SECRET --confirm',
59
+ '# Write the [Default] rules (inherited by every environment without its own):',
60
+ '<%= config.bin %> <%= command.id %> my.secret --environment="[Default]" --secret --value-from-env=MY_SECRET --confirm',
45
61
  '<%= config.bin %> <%= command.id %> my.config.name --env-var=MY_ENV_VAR_NAME --environment=production',
46
62
  '# For a percentage rollout, use set-rollout instead:',
47
63
  '<%= config.bin %> set-rollout my.flag.name --environment production --true-percent 20',
@@ -50,15 +66,20 @@ To target specific organizations or users, write rules on their KEY
50
66
  ];
51
67
  static flags = {
52
68
  confidential: Flags.boolean({ default: false, description: 'mark the value as confidential' }),
53
- 'env-var': Flags.string({ description: 'environment variable to use as default value' }),
54
- environment: Flags.string({ description: 'environment to change' }),
69
+ 'env-var': Flags.string({
70
+ description: 'store no value: the SDK reads it from this env var at RUNTIME. To read a value from a local env var NOW, use --value-from-env',
71
+ }),
72
+ environment: Flags.string({
73
+ description: 'environment to change, e.g. production. Use "[Default]" for the default rules that every environment without its own rules inherits',
74
+ }),
55
75
  'replace-targeting': Flags.boolean({
56
76
  default: false,
57
77
  description: "set the value for EVERYONE: delete this environment's targeting rules instead of keeping them (they stay in git history)",
58
78
  }),
59
- value: Flags.string({ description: 'new default value' }),
79
+ value: Flags.string({ description: 'new default value (for secrets prefer --value-stdin / --value-from-env)' }),
80
+ ...valueSourceFlags,
60
81
  ...confirmFlag,
61
- ...secretFlags('encrypt the value of this item'),
82
+ ...secretFlags('encrypt the value of this item (string-only; the CLI never prints the plaintext)'),
62
83
  };
63
84
  async run() {
64
85
  const { args, flags } = await this.parse(SetDefault);
@@ -69,6 +90,11 @@ To target specific organizations or users, write rules on their KEY
69
90
  if (flags['env-var'] && secret.selected) {
70
91
  return this.err('cannot specify both --env-var and --secret');
71
92
  }
93
+ // qfg-38lp: --value / --value-stdin / --value-from-env all resolve here.
94
+ const valueSource = await resolveValueSource(flags);
95
+ if (!valueSource.ok) {
96
+ return this.err(valueSource.error ? valueSource.message : 'Value is required');
97
+ }
72
98
  if (flags.confidential && secret.selected) {
73
99
  console.warn("Note: --confidential is implied when using --secret, so you don't need to specify both.");
74
100
  }
@@ -107,6 +133,13 @@ To target specific organizations or users, write rules on their KEY
107
133
  return this.err(`Could not find config named ${key}`);
108
134
  }
109
135
  this.verboseLog('Selected config:', config);
136
+ // qfg-38lp: secrets are string-only end to end. Say so here instead of
137
+ // letting the server answer with a bare 422.
138
+ if (secret.selected && config.valueType.toLowerCase() !== 'string') {
139
+ return this.err(`secrets are string-only; ${key} is type ${config.valueType}. ` +
140
+ 'Store JSON (or any structured value) as a string secret instead — e.g. ' +
141
+ '`qfg create <new-key> --type string --secret --value-stdin < value.json` — and JSON.parse it in your app.', { key, phase: 'validation', valueType: config.valueType });
142
+ }
110
143
  // Fetch the stored document ONCE. It drives encryption auto-detection,
111
144
  // the targeting-rule count in the confirmation prompt, the rules the write
112
145
  // is applied to, and the commitSha for optimistic concurrency.
@@ -179,18 +212,23 @@ To target specific organizations or users, write rules on their KEY
179
212
  targetingRuleCount,
180
213
  });
181
214
  }
182
- let value = flags.value;
215
+ let value = valueSource.value;
216
+ // A secret / confidential value is masked while typed and never shown.
217
+ const hideValue = secret.selected || confidential;
183
218
  if (!value && isInteractive(flags)) {
184
219
  value = await getString({
185
220
  allowBlank: true,
186
- message: 'Default value',
221
+ mask: hideValue,
222
+ message: hideValue ? 'Secret value (input is masked)' : 'Default value',
187
223
  });
188
224
  }
189
225
  if (value === undefined) {
190
226
  return this.err('Value is required');
191
227
  }
192
- const secretMaybe = secret.selected ? ' (encrypted)' : '';
193
- if (!(await getConfirmation({ flags, message: confirmMessage(`\`${value}\`${secretMaybe}`) }))) {
228
+ const promptPhrase = hideValue
229
+ ? redactedDisplay(redactedSummary({ encrypted: secret.selected, plaintext: value }))
230
+ : `\`${value}\``;
231
+ if (!(await getConfirmation({ flags, message: confirmMessage(promptPhrase) }))) {
194
232
  return;
195
233
  }
196
234
  return this.submitChange({
@@ -225,8 +263,17 @@ To target specific organizations or users, write rules on their KEY
225
263
  // "be provided by `MY_ENV_VAR`" — plus any (encrypted)/(confidential) note.
226
264
  let valuePhrase;
227
265
  let modifiers = '';
266
+ // Set whenever the value must not be printed (secret or confidential), qfg-38lp.
267
+ let redacted;
268
+ // How the value may appear in an error message.
269
+ let shownValue = value;
228
270
  if (envVar === undefined) {
229
271
  valuePhrase = `\`${value}\``;
272
+ if (confidential && !secret.selected) {
273
+ redacted = redactedSummary({ plaintext: value });
274
+ valuePhrase = redactedDisplay(redacted);
275
+ shownValue = valuePhrase;
276
+ }
230
277
  if (secret.selected) {
231
278
  // Handle encrypted values using shared utility
232
279
  const encryptedValueResult = await makeConfidentialValue(this, value, secret, environment.name);
@@ -234,7 +281,9 @@ To target specific organizations or users, write rules on their KEY
234
281
  return this.err(encryptedValueResult.message || 'Failed to encrypt value');
235
282
  }
236
283
  configValue = mapConfigValueToDto(encryptedValueResult.value, ConfigValueType.String);
237
- modifiers += ' (encrypted)';
284
+ redacted = redactedSummary({ ciphertext: String(encryptedValueResult.value.value), plaintext: value });
285
+ valuePhrase = redactedDisplay(redacted);
286
+ shownValue = valuePhrase;
238
287
  }
239
288
  else {
240
289
  // Parse the value based on type and build value object
@@ -249,7 +298,7 @@ To target specific organizations or users, write rules on their KEY
249
298
  case 'bool': {
250
299
  const lowerValue = value.toLowerCase();
251
300
  if (lowerValue !== 'true' && lowerValue !== 'false') {
252
- return this.err(`'${value}' is not a valid value for ${key}`);
301
+ return this.err(`'${shownValue}' is not a valid value for ${key}`);
253
302
  }
254
303
  variantValue = lowerValue === 'true';
255
304
  break;
@@ -258,14 +307,14 @@ To target specific organizations or users, write rules on their KEY
258
307
  // Shared strict grammar with qfg create (qfg-2agi.23).
259
308
  variantValue = parseIntValue(value);
260
309
  if (variantValue === undefined) {
261
- return this.err(`Invalid default value for int: ${value}`);
310
+ return this.err(`Invalid default value for int: ${shownValue}`);
262
311
  }
263
312
  break;
264
313
  }
265
314
  case 'double': {
266
315
  variantValue = parseDoubleValue(value);
267
316
  if (variantValue === undefined) {
268
- return this.err(`Invalid default value for double: ${value}`);
317
+ return this.err(`Invalid default value for double: ${shownValue}`);
269
318
  }
270
319
  break;
271
320
  }
@@ -274,7 +323,7 @@ To target specific organizations or users, write rules on their KEY
274
323
  variantValue = JSON.parse(value);
275
324
  }
276
325
  catch {
277
- return this.err(`Invalid JSON value: ${value}`);
326
+ return this.err(`Invalid JSON value: ${shownValue}`);
278
327
  }
279
328
  break;
280
329
  }
@@ -285,7 +334,7 @@ To target specific organizations or users, write rules on their KEY
285
334
  case 'duration': {
286
335
  // Stored verbatim as the ISO 8601 string; no conversions (qfg-2agi.3).
287
336
  if (!isValidIsoDuration(value)) {
288
- return this.err(`Invalid value for duration: ${value}. ${DURATION_FORMAT_HINT}`);
337
+ return this.err(`Invalid value for duration: ${shownValue}. ${DURATION_FORMAT_HINT}`);
289
338
  }
290
339
  break;
291
340
  }
@@ -305,7 +354,9 @@ To target specific organizations or users, write rules on their KEY
305
354
  }
306
355
  if (confidential && !secret.selected) {
307
356
  configValue.confidential = true;
308
- modifiers += ' (confidential)';
357
+ // the redacted display already says "confidential"
358
+ if (!redacted)
359
+ modifiers += ' (confidential)';
309
360
  }
310
361
  // Surgical by default (qfg-qjdm): the fallback rule's value is replaced
311
362
  // in place and every targeting rule around it is kept. --replace-targeting
@@ -374,13 +425,14 @@ To target specific organizations or users, write rules on their KEY
374
425
  },
375
426
  key,
376
427
  success: true,
377
- value,
428
+ // qfg-38lp: a secret/confidential value is reported redacted, never verbatim.
429
+ value: redacted ?? value,
378
430
  ...counts,
379
431
  };
380
432
  }
381
433
  this.verboseLog(request.error);
382
434
  const errMsg = request.status === 400
383
- ? `Failed to change default: ${request.status} -- is ${value || envVar} a valid value?`
435
+ ? `Failed to change default: ${request.status} -- is ${shownValue || envVar} a valid value?`
384
436
  : `Failed to change default: ${request.status}`;
385
437
  return this.err(errMsg, { key, serverError: request.error });
386
438
  }
package/dist/index.d.ts CHANGED
@@ -50,6 +50,12 @@ export declare abstract class BaseCommand extends Command {
50
50
  protected toErrorJson(err: unknown): {
51
51
  error: JsonObj;
52
52
  };
53
+ /**
54
+ * qfg-38lp: a value typed as a stray positional (`qfg set-default KEY <secret>`)
55
+ * comes back from oclif as `Unexpected argument: <secret>`. On commands that
56
+ * take a value, hide the offending tokens instead of echoing them.
57
+ */
58
+ private redactValueParseError;
53
59
  }
54
60
  export declare abstract class APICommand extends BaseCommand {
55
61
  static baseFlags: {
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { run } from '@oclif/core';
2
2
  import { Command, Errors, Flags } from '@oclif/core';
3
3
  import rawGetClient, { unwrapRequest } from './util/get-client.js';
4
+ import { scrubForLog } from './util/redact.js';
4
5
  const globalFlags = {
5
6
  interactive: Flags.boolean({
6
7
  allowNo: true,
@@ -109,11 +110,15 @@ export class BaseCommand extends Command {
109
110
  verboseLog = (category, message) => {
110
111
  if (!this.isVerbose)
111
112
  return;
113
+ // qfg-38lp: verbose output (request bodies, API responses, stored config
114
+ // documents) must never carry a confidential plaintext.
112
115
  if (message) {
113
- this.logToStderr(`[${category}] ${typeof message === 'string' ? message : JSON.stringify(message)}`);
116
+ const scrubbed = scrubForLog(message);
117
+ this.logToStderr(`[${category}] ${typeof scrubbed === 'string' ? scrubbed : JSON.stringify(scrubbed)}`);
114
118
  }
115
119
  else {
116
- this.logToStderr(typeof category === 'string' ? category : JSON.stringify(category));
120
+ const scrubbed = scrubForLog(category);
121
+ this.logToStderr(typeof scrubbed === 'string' ? scrubbed : JSON.stringify(scrubbed));
117
122
  }
118
123
  };
119
124
  async catch(err) {
@@ -127,6 +132,7 @@ export class BaseCommand extends Command {
127
132
  if (err instanceof Errors.ExitError || err.code === 'EEXIT') {
128
133
  throw err;
129
134
  }
135
+ this.redactValueParseError(err);
130
136
  // qfg-hzmb: `this.log()` is a documented no-op whenever jsonEnabled(), so
131
137
  // this override used to print NOTHING for a failing `--json` run — empty
132
138
  // stdout was indistinguishable from an empty success. Emit the structured
@@ -162,9 +168,12 @@ export class BaseCommand extends Command {
162
168
  // An Error's own *enumerable* properties are the structured payload handed
163
169
  // to `err()` — keep those. `oclif` is internal bookkeeping (surfaced as
164
170
  // `exitCode`), and `stack` is dropped on purpose (oclif.stacktrace=false).
171
+ // `parse` is oclif's parse-error context: hundreds of KB of config dump
172
+ // that includes the raw argv, so a `--value=<secret>` landed on stdout
173
+ // (qfg-38lp). The message already names the bad flag; drop the dump.
165
174
  const payload = {};
166
175
  for (const [key, value] of Object.entries(err)) {
167
- if (key !== 'oclif' && key !== 'stack')
176
+ if (key !== 'oclif' && key !== 'stack' && key !== 'parse')
168
177
  payload[key] = value;
169
178
  }
170
179
  const { code } = err;
@@ -177,6 +186,29 @@ export class BaseCommand extends Command {
177
186
  },
178
187
  };
179
188
  }
189
+ /**
190
+ * qfg-38lp: a value typed as a stray positional (`qfg set-default KEY <secret>`)
191
+ * comes back from oclif as `Unexpected argument: <secret>`. On commands that
192
+ * take a value, hide the offending tokens instead of echoing them.
193
+ */
194
+ redactValueParseError(err) {
195
+ const flags = this.ctor.flags ?? {};
196
+ if (!('value' in flags))
197
+ return;
198
+ if (err.message.startsWith('Unexpected argument')) {
199
+ const count = Array.isArray(err.args) ? err.args.length : 1;
200
+ err.message =
201
+ `Unexpected argument${count === 1 ? '' : 's'} (${count} hidden, in case ${count === 1 ? 'it is' : 'they are'} a value). ` +
202
+ 'Pass a value with --value, --value-stdin or --value-from-env=VAR, not as a positional argument.';
203
+ delete err.args;
204
+ return;
205
+ }
206
+ if (err.message.startsWith('Nonexistent flag')) {
207
+ err.message = err.message.replaceAll(/=\S+/g, '=<hidden>');
208
+ if (Array.isArray(err.flags))
209
+ err.flags = err.flags.map((f) => String(f).replace(/=.*$/, '=<hidden>'));
210
+ }
211
+ }
180
212
  }
181
213
  export class APICommand extends BaseCommand {
182
214
  static baseFlags = {
@@ -1,2 +1,20 @@
1
1
  import { Config } from '@oclif/core/lib/interfaces/index.js';
2
+ import Create from './commands/create.js';
3
+ import GenerateNewHexKey from './commands/generate-new-hex-key.js';
4
+ import Get from './commands/get.js';
5
+ import Info from './commands/info.js';
6
+ import List from './commands/list.js';
7
+ import Override from './commands/override.js';
8
+ import SetDefault from './commands/set-default.js';
9
+ type SuggestedCommand = {
10
+ command: typeof Create | typeof GenerateNewHexKey | typeof Get | typeof Info | typeof List | typeof Override | typeof SetDefault;
11
+ description: string;
12
+ displayCommandName: string;
13
+ id: string;
14
+ implicitFlags: string[];
15
+ };
16
+ declare const commands: SuggestedCommand[];
17
+ type AvailableCommand = (typeof commands)[number];
18
+ export declare const getFlags: (flags: AvailableCommand["command"]["flags"], commandId: string, implicitFlags: string[], command: SuggestedCommand["command"]) => Promise<string[]>;
2
19
  export declare const interactivePrompt: (config: Config) => Promise<void>;
20
+ export {};
@@ -94,14 +94,23 @@ const getArgs = async (args, commandId, command) => {
94
94
  // Filter out undefined values (OAuth commands handle their own name prompting)
95
95
  return values.filter((v) => v !== undefined);
96
96
  };
97
- const getFlags = async (flags, commandId, implicitFlags, command) => {
97
+ export const getFlags = async (flags, commandId, implicitFlags, command) => {
98
98
  if (!flags && implicitFlags.length === 0) {
99
99
  return [];
100
100
  }
101
101
  const inputs = [];
102
102
  const promiseInputs = [];
103
+ // qfg-38lp: secrets are string-only, so "create --secret" never asks for a type.
104
+ const forceStringType = commandId === 'create' && implicitFlags.includes('secret');
103
105
  for (const [key, arg] of Object.entries(flags)) {
104
- if (arg.required || implicitFlags.includes(key)) {
106
+ if (forceStringType && key === 'type') {
107
+ inputs.push('--type=string');
108
+ continue;
109
+ }
110
+ // `create --type` is optional on the command line (--secret implies string)
111
+ // but the interactive create still asks for it.
112
+ const promptForType = commandId === 'create' && key === 'type';
113
+ if (arg.required || promptForType || implicitFlags.includes(key)) {
105
114
  if (arg.type === 'boolean') {
106
115
  inputs.push(`--${key}`);
107
116
  }
@@ -9,8 +9,10 @@
9
9
  * provider extends the table rather than reinventing the dispatch.
10
10
  */
11
11
  /**
12
- * Operators that have a direct negated form in Quonfig. `negate: true` simply
13
- * selects the negated operator — semantics-preserving, always converted.
12
+ * Operators that have a direct negated form in Quonfig. `negate: true` selects
13
+ * the negated operator and always converts. LD never matches a context missing
14
+ * the attribute, so `clauseToCriteria` (ruleset.ts) ANDs an IS_PRESENT guard in
15
+ * front of each negated property operator to keep that (qfg-goi1.5.10).
14
16
  */
15
17
  const NEGATABLE = {
16
18
  contains: { negated: 'PROP_DOES_NOT_CONTAIN_ONE_OF', plain: 'PROP_CONTAINS_ONE_OF' },
@@ -91,6 +91,27 @@ export interface RulesetContext {
91
91
  * becomes a dotted path (`address.city`).
92
92
  */
93
93
  export declare function normalizeAttribute(contextKind: string | undefined, attribute: string): string;
94
+ /**
95
+ * Join several regex patterns into one alternation `(?:a)|(?:b)` (qfg-ktkr.6).
96
+ * Each alternative is wrapped in a non-capturing group so anchors and inner
97
+ * `|` stay scoped to their own pattern. The join adds no quantified group, so
98
+ * it stays inside the v1 safety rule as long as every pattern is in v1 and
99
+ * the length and unbounded-quantifier caps hold; returns `null` otherwise
100
+ * (qfg-ktkr.2: the full checkRegexV1, not just the caps).
101
+ */
102
+ export declare function joinRegexAlternation(patterns: string[]): null | string;
103
+ /**
104
+ * Expand a provider rule's clauses into the OR-alternatives of clause lists
105
+ * that preserve LaunchDarkly semantics (qfg-ktkr.6). LD ORs a clause's values;
106
+ * Quonfig has no within-rule OR, so a multi-value `matches` clause whose
107
+ * patterns cannot be joined within the v1 caps is:
108
+ * - negated (matches none): split into single-pattern negated clauses ANDed
109
+ * in the same rule;
110
+ * - positive (matches any): fanned out into one alternative per pattern.
111
+ * Alternatives serve the same value, so first-match-wins over them is the OR.
112
+ * Returns `null` (rule dropped + reported) past MAX_SPLIT_RULES alternatives.
113
+ */
114
+ export declare function expandClauses(clauses: SourceClause[], ctx: RulesetContext): null | SourceClause[][];
94
115
  /**
95
116
  * Map one provider clause to a Quonfig criterion, or `null` when the clause's
96
117
  * operator has no v1 mapping (D3 — negated comparisons, applicationVersion…).
@@ -98,12 +119,39 @@ export declare function normalizeAttribute(contextKind: string | undefined, attr
98
119
  * partial AND would silently broaden targeting.
99
120
  */
100
121
  export declare function clauseToCriterion(clause: SourceClause, ctx: RulesetContext): QuonfigCriterion | null;
122
+ /**
123
+ * Map one provider clause to the Quonfig criteria it ANDs into a rule, or
124
+ * `null` when the clause cannot be mapped (see `clauseToCriterion`).
125
+ *
126
+ * A negated property clause (`in`, `contains`, `startsWith`, `endsWith`,
127
+ * `matches` with `negate: true`) gets an `IS_PRESENT` guard on the same
128
+ * property ANDed in front of it (qfg-goi1.5.10, operator-semantics Q6b).
129
+ * LaunchDarkly never matches a context that lacks the clause attribute, negated
130
+ * or not; Quonfig's negated operators do match a missing attribute (Q6a, kept),
131
+ * so without the guard a migrated rule would broaden to every context missing
132
+ * the attribute. Negated `segmentMatch` (NOT_IN_SEG) has no property and is not
133
+ * guarded. This only shapes NEW migrations; already-migrated workspaces are
134
+ * never rewritten.
135
+ */
136
+ export declare function clauseToCriteria(clause: SourceClause, ctx: RulesetContext): QuonfigCriterion[] | null;
101
137
  /**
102
138
  * Build a Quonfig rule from a provider rule's clauses (AND semantics) plus its
103
139
  * served value. Returns `null` when any clause cannot be mapped — the rule is
104
140
  * dropped wholesale and reported, rather than silently broadened.
105
141
  */
106
142
  export declare function buildRule(clauses: SourceClause[], value: QuonfigRuleValue, ctx: RulesetContext): QuonfigRule | null;
143
+ /**
144
+ * AND a clause list into criteria, or `null` when any clause is unmappable.
145
+ * An IS_PRESENT guard already in the list is not repeated (a split negated
146
+ * multi-pattern clause guards its property once).
147
+ */
148
+ export declare function clausesToCriteria(clauses: SourceClause[], ctx: RulesetContext): null | QuonfigCriterion[];
149
+ /**
150
+ * Build the Quonfig rules for one provider rule: `buildRule` over each
151
+ * alternative from `expandClauses` (qfg-ktkr.6). Usually one rule; empty when
152
+ * the rule is dropped (and reported).
153
+ */
154
+ export declare function buildRules(clauses: SourceClause[], value: QuonfigRuleValue, ctx: RulesetContext): QuonfigRule[];
107
155
  /**
108
156
  * Convert a provider rollout to a Quonfig `weighted_values`. The experiment
109
157
  * framing (`kind: "experiment"` + `seed`) is dropped + reported (plan §5.4),
@@ -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 { checkRegexV1 } from '../../verify/regex-dialect.js';
11
12
  import { resolveKey } from '../key-rewriter.js';
12
13
  import { mapLaunchDarklyOperator } from './operators.js';
13
14
  import { toQuonfigValue } from './values.js';
@@ -41,6 +42,57 @@ export function normalizeAttribute(contextKind, attribute) {
41
42
  const dotted = attribute.replace(/^\//, '').replaceAll('/', '.');
42
43
  return `${kind}.${dotted}`;
43
44
  }
45
+ /** Upper bound on rules one LD rule may fan out into when patterns are split. */
46
+ const MAX_SPLIT_RULES = 16;
47
+ /**
48
+ * Join several regex patterns into one alternation `(?:a)|(?:b)` (qfg-ktkr.6).
49
+ * Each alternative is wrapped in a non-capturing group so anchors and inner
50
+ * `|` stay scoped to their own pattern. The join adds no quantified group, so
51
+ * it stays inside the v1 safety rule as long as every pattern is in v1 and
52
+ * the length and unbounded-quantifier caps hold; returns `null` otherwise
53
+ * (qfg-ktkr.2: the full checkRegexV1, not just the caps).
54
+ */
55
+ export function joinRegexAlternation(patterns) {
56
+ const joined = patterns.map((p) => `(?:${p})`).join('|');
57
+ return checkRegexV1(joined) === null ? joined : null;
58
+ }
59
+ /** A multi-value `matches` clause whose patterns cannot be joined within v1. */
60
+ function isUnjoinableMatches(clause) {
61
+ return clause.op === 'matches' && clause.values.length > 1 && joinRegexAlternation(clause.values.map(String)) === null;
62
+ }
63
+ /**
64
+ * Expand a provider rule's clauses into the OR-alternatives of clause lists
65
+ * that preserve LaunchDarkly semantics (qfg-ktkr.6). LD ORs a clause's values;
66
+ * Quonfig has no within-rule OR, so a multi-value `matches` clause whose
67
+ * patterns cannot be joined within the v1 caps is:
68
+ * - negated (matches none): split into single-pattern negated clauses ANDed
69
+ * in the same rule;
70
+ * - positive (matches any): fanned out into one alternative per pattern.
71
+ * Alternatives serve the same value, so first-match-wins over them is the OR.
72
+ * Returns `null` (rule dropped + reported) past MAX_SPLIT_RULES alternatives.
73
+ */
74
+ export function expandClauses(clauses, ctx) {
75
+ let alternatives = [[]];
76
+ for (const clause of clauses) {
77
+ if (!isUnjoinableMatches(clause)) {
78
+ for (const alt of alternatives)
79
+ alt.push(clause);
80
+ continue;
81
+ }
82
+ const singles = clause.values.map((v) => ({ ...clause, values: [v] }));
83
+ if (clause.negate) {
84
+ for (const alt of alternatives)
85
+ alt.push(...singles);
86
+ continue;
87
+ }
88
+ alternatives = alternatives.flatMap((alt) => singles.map((single) => [...alt, single]));
89
+ if (alternatives.length > MAX_SPLIT_RULES) {
90
+ ctx.report.add('skipped-rule', ctx.sourceKey, `rule dropped: its regex clauses expand to more than ${MAX_SPLIT_RULES} rules (patterns too long or too complex to join within Quonfig regex v1)`);
91
+ return null;
92
+ }
93
+ }
94
+ return alternatives;
95
+ }
44
96
  /** Build a `valueToMatch` typed value for a non-presence, non-segment clause. */
45
97
  function valueToMatchFor(operator, clause, ctx) {
46
98
  if (STRING_LIST_OPERATORS.has(operator)) {
@@ -60,6 +112,11 @@ function valueToMatchFor(operator, clause, ctx) {
60
112
  }
61
113
  if (operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') {
62
114
  if (clause.values.length > 1) {
115
+ // LD ORs the patterns (qfg-ktkr.6). `expandClauses` splits any set too
116
+ // big to join, so this only falls back for a direct caller.
117
+ const joined = joinRegexAlternation(clause.values.map(String));
118
+ if (joined !== null)
119
+ return { type: 'string', value: joined };
63
120
  ctx.report.add('skipped-rule', ctx.sourceKey, `regex clause listed ${clause.values.length} patterns; Quonfig PROP_MATCHES takes one — only the first kept`);
64
121
  }
65
122
  return { type: 'string', value: String(clause.values[0] ?? '') };
@@ -97,23 +154,92 @@ export function clauseToCriterion(clause, ctx) {
97
154
  ? { operator }
98
155
  : { operator, propertyName: normalizeAttribute(clause.contextKind, clause.attribute) };
99
156
  criterion.valueToMatch = valueToMatchFor(operator, clause, ctx);
157
+ // qfg-ktkr.2: a pattern outside Quonfig regex v1 would be refused by
158
+ // `qfg verify` and the Git hook, so drop the rule and report why instead of
159
+ // writing a file nobody can push.
160
+ if ((operator === 'PROP_MATCHES' || operator === 'PROP_DOES_NOT_MATCH') && criterion.valueToMatch) {
161
+ const pattern = String(criterion.valueToMatch.value);
162
+ const violation = checkRegexV1(pattern);
163
+ if (violation) {
164
+ ctx.report.add('skipped-rule', ctx.sourceKey, `regex clause on "${clause.attribute}" skipped: pattern "${pattern}" is outside Quonfig regex v1 (${violation.reason}). ${violation.hint}`);
165
+ return null;
166
+ }
167
+ }
100
168
  return criterion;
101
169
  }
170
+ /**
171
+ * Map one provider clause to the Quonfig criteria it ANDs into a rule, or
172
+ * `null` when the clause cannot be mapped (see `clauseToCriterion`).
173
+ *
174
+ * A negated property clause (`in`, `contains`, `startsWith`, `endsWith`,
175
+ * `matches` with `negate: true`) gets an `IS_PRESENT` guard on the same
176
+ * property ANDed in front of it (qfg-goi1.5.10, operator-semantics Q6b).
177
+ * LaunchDarkly never matches a context that lacks the clause attribute, negated
178
+ * or not; Quonfig's negated operators do match a missing attribute (Q6a, kept),
179
+ * so without the guard a migrated rule would broaden to every context missing
180
+ * the attribute. Negated `segmentMatch` (NOT_IN_SEG) has no property and is not
181
+ * guarded. This only shapes NEW migrations; already-migrated workspaces are
182
+ * never rewritten.
183
+ */
184
+ export function clauseToCriteria(clause, ctx) {
185
+ const criterion = clauseToCriterion(clause, ctx);
186
+ if (criterion === null)
187
+ return null;
188
+ if (clause.negate && criterion.propertyName !== undefined) {
189
+ return [{ operator: 'IS_PRESENT', propertyName: criterion.propertyName }, criterion];
190
+ }
191
+ return [criterion];
192
+ }
102
193
  /**
103
194
  * Build a Quonfig rule from a provider rule's clauses (AND semantics) plus its
104
195
  * served value. Returns `null` when any clause cannot be mapped — the rule is
105
196
  * dropped wholesale and reported, rather than silently broadened.
106
197
  */
107
198
  export function buildRule(clauses, value, ctx) {
199
+ const criteria = clausesToCriteria(clauses, ctx);
200
+ if (criteria === null)
201
+ return null;
202
+ // A rule with no clauses matches everyone — represent that as ALWAYS_TRUE.
203
+ return { criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE], value };
204
+ }
205
+ /**
206
+ * AND a clause list into criteria, or `null` when any clause is unmappable.
207
+ * An IS_PRESENT guard already in the list is not repeated (a split negated
208
+ * multi-pattern clause guards its property once).
209
+ */
210
+ export function clausesToCriteria(clauses, ctx) {
108
211
  const criteria = [];
109
212
  for (const clause of clauses) {
110
- const criterion = clauseToCriterion(clause, ctx);
111
- if (criterion === null)
213
+ const mapped = clauseToCriteria(clause, ctx);
214
+ if (mapped === null)
112
215
  return null;
113
- criteria.push(criterion);
216
+ for (const c of mapped) {
217
+ const duplicateGuard = c.operator === 'IS_PRESENT' &&
218
+ criteria.some((e) => e.operator === 'IS_PRESENT' && e.propertyName === c.propertyName);
219
+ if (!duplicateGuard)
220
+ criteria.push(c);
221
+ }
114
222
  }
115
- // A rule with no clauses matches everyone — represent that as ALWAYS_TRUE.
116
- return { criteria: criteria.length > 0 ? criteria : [ALWAYS_TRUE], value };
223
+ return criteria;
224
+ }
225
+ /**
226
+ * Build the Quonfig rules for one provider rule: `buildRule` over each
227
+ * alternative from `expandClauses` (qfg-ktkr.6). Usually one rule; empty when
228
+ * the rule is dropped (and reported).
229
+ */
230
+ export function buildRules(clauses, value, ctx) {
231
+ const alternatives = expandClauses(clauses, ctx);
232
+ if (alternatives === null)
233
+ return [];
234
+ const rules = [];
235
+ for (const alt of alternatives) {
236
+ const rule = buildRule(alt, value, ctx);
237
+ // An unmappable clause fails every alternative alike — report it once.
238
+ if (rule === null)
239
+ return [];
240
+ rules.push(rule);
241
+ }
242
+ return rules;
117
243
  }
118
244
  /** Resolve a variation index to its typed Quonfig value. */
119
245
  function variationValue(index, ctx) {
@@ -202,9 +328,7 @@ export function collapseEnvironment(env, ctx) {
202
328
  }
203
329
  for (const rule of env.rules ?? []) {
204
330
  const value = rule.rollout === undefined ? variationValue(rule.variation ?? 0, ctx) : rolloutToWeightedValues(rule.rollout, ctx);
205
- const built = buildRule(rule.clauses, value, ctx);
206
- if (built !== null)
207
- rules.push(built);
331
+ rules.push(...buildRules(rule.clauses, value, ctx));
208
332
  }
209
333
  if (env.fallthrough) {
210
334
  rules.push({ criteria: [ALWAYS_TRUE], value: servedValue(env.fallthrough, ctx) });
@@ -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 { checkRegexV1 } from '../../../verify/regex-dialect.js';
18
19
  import { resolveKey } from '../../key-rewriter.js';
19
20
  import { inferValueType, toQuonfigValue } from '../../quonfig-target/values.js';
20
21
  import { normalizeImportedWeights } from '../../quonfig-target/weights.js';
@@ -270,6 +271,16 @@ export function mapCondition(condition) {
270
271
  };
271
272
  }
272
273
  case 'REGEX': {
274
+ // qfg-ktkr.2: a pattern outside Quonfig regex v1 would be refused by
275
+ // `qfg verify` and the Git hook; drop the rule with the reason instead.
276
+ const pattern = String(value ?? '');
277
+ const violation = checkRegexV1(pattern);
278
+ if (violation) {
279
+ return {
280
+ drop: true,
281
+ reason: `pattern "${pattern}" is outside Quonfig regex v1 (${violation.reason}). ${violation.hint}`,
282
+ };
283
+ }
273
284
  return {
274
285
  criterion: {
275
286
  operator: 'PROP_MATCHES',