@quonfig/cli 0.0.11 → 0.0.13

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 (106) hide show
  1. package/README.md +214 -30
  2. package/dist/codegen/json-schema-to-zod.js +7 -5
  3. package/dist/codegen/language-mappers/zod-base-mapper.d.ts +2 -2
  4. package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +2 -2
  5. package/dist/codegen/language-mappers/zod-to-string-mapper.js +6 -6
  6. package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +2 -2
  7. package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +6 -6
  8. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +2 -2
  9. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +5 -5
  10. package/dist/codegen/local-config-reader.js +0 -1
  11. package/dist/codegen/schema-extractor.js +5 -2
  12. package/dist/commands/info.js +4 -1
  13. package/dist/commands/init.js +12 -2
  14. package/dist/commands/interactive.d.ts +1 -0
  15. package/dist/commands/interactive.js +8 -0
  16. package/dist/commands/login.js +9 -3
  17. package/dist/commands/migrate/doctor.d.ts +12 -0
  18. package/dist/commands/migrate/doctor.js +63 -0
  19. package/dist/commands/migrate/my-code.d.ts +11 -0
  20. package/dist/commands/migrate/my-code.js +80 -0
  21. package/dist/commands/migrate/status.d.ts +10 -0
  22. package/dist/commands/migrate/status.js +99 -0
  23. package/dist/commands/migrate.d.ts +20 -0
  24. package/dist/commands/migrate.js +277 -0
  25. package/dist/commands/override.js +6 -3
  26. package/dist/commands/profile.d.ts +1 -1
  27. package/dist/commands/profile.js +1 -1
  28. package/dist/commands/pull.d.ts +2 -2
  29. package/dist/commands/pull.js +38 -41
  30. package/dist/commands/schema.d.ts +3 -3
  31. package/dist/commands/schema.js +37 -37
  32. package/dist/commands/sdk-key/create.js +12 -14
  33. package/dist/commands/sdk-key/revoke.d.ts +1 -1
  34. package/dist/commands/sdk-key/revoke.js +2 -4
  35. package/dist/commands/set-default.js +7 -5
  36. package/dist/commands/set-rollout.d.ts +1 -1
  37. package/dist/commands/set-rollout.js +16 -13
  38. package/dist/commands/sync.d.ts +3 -3
  39. package/dist/commands/sync.js +30 -26
  40. package/dist/commands/whoami.js +1 -5
  41. package/dist/commands/workspace/bootstrap.js +7 -7
  42. package/dist/commands/workspace/switch.js +2 -2
  43. package/dist/init/init-workspace.d.ts +2 -2
  44. package/dist/init/init-workspace.js +32 -18
  45. package/dist/init/samples.d.ts +1 -1
  46. package/dist/init/samples.js +2 -2
  47. package/dist/init/schema.js +24 -15
  48. package/dist/interactive-prompt.js +1 -10
  49. package/dist/migrate/doctor.d.ts +27 -0
  50. package/dist/migrate/doctor.js +222 -0
  51. package/dist/migrate/identifier-map.d.ts +14 -0
  52. package/dist/migrate/identifier-map.js +55 -0
  53. package/dist/migrate/import-state.d.ts +14 -0
  54. package/dist/migrate/import-state.js +60 -0
  55. package/dist/migrate/local-write.d.ts +26 -0
  56. package/dist/migrate/local-write.js +85 -0
  57. package/dist/migrate/migration-report.d.ts +42 -0
  58. package/dist/migrate/migration-report.js +85 -0
  59. package/dist/migrate/push-strategy.d.ts +34 -0
  60. package/dist/migrate/push-strategy.js +127 -0
  61. package/dist/migrate/push-to-cloud.d.ts +15 -0
  62. package/dist/migrate/push-to-cloud.js +31 -0
  63. package/dist/migrate/registry.d.ts +6 -0
  64. package/dist/migrate/registry.js +25 -0
  65. package/dist/migrate/source.d.ts +22 -0
  66. package/dist/migrate/source.js +12 -0
  67. package/dist/migrate/sources/flagsmith.d.ts +2 -0
  68. package/dist/migrate/sources/flagsmith.js +28 -0
  69. package/dist/migrate/sources/launch/api.d.ts +7 -0
  70. package/dist/migrate/sources/launch/api.js +75 -0
  71. package/dist/migrate/sources/launch/translate.d.ts +6 -0
  72. package/dist/migrate/sources/launch/translate.js +190 -0
  73. package/dist/migrate/sources/launch/types.d.ts +68 -0
  74. package/dist/migrate/sources/launch/types.js +1 -0
  75. package/dist/migrate/sources/launch.d.ts +3 -0
  76. package/dist/migrate/sources/launch.js +70 -0
  77. package/dist/migrate/sources/launchdarkly.d.ts +2 -0
  78. package/dist/migrate/sources/launchdarkly.js +18 -0
  79. package/dist/quonfig.d.ts +1 -1
  80. package/dist/quonfig.js +5 -9
  81. package/dist/ui/get-confirmation.d.ts +2 -1
  82. package/dist/ui/get-confirmation.js +9 -1
  83. package/dist/ui/get-value.d.ts +1 -1
  84. package/dist/ui/get-value.js +4 -3
  85. package/dist/util/config-value-dto.js +17 -1
  86. package/dist/util/encryption.js +4 -1
  87. package/dist/util/friction-log.d.ts +1 -1
  88. package/dist/util/friction-log.js +5 -1
  89. package/dist/util/get-client.js +51 -3
  90. package/dist/util/get-valid-token.d.ts +4 -0
  91. package/dist/util/get-valid-token.js +17 -2
  92. package/dist/util/git-ops.js +7 -7
  93. package/dist/util/gitea-api.d.ts +2 -2
  94. package/dist/util/gitea-api.js +5 -3
  95. package/dist/util/gitea-token-storage.d.ts +2 -2
  96. package/dist/util/gitea-token-storage.js +1 -1
  97. package/dist/util/is-interactive.d.ts +21 -1
  98. package/dist/util/is-interactive.js +21 -1
  99. package/dist/util/oauth-client.js +1 -1
  100. package/dist/util/token-storage.js +2 -2
  101. package/dist/verify/standalone.d.ts +0 -1
  102. package/dist/verify/standalone.js +22 -23
  103. package/dist/verify/validate.d.ts +8 -8
  104. package/dist/verify/validate.js +256 -245
  105. package/oclif.manifest.json +365 -3
  106. package/package.json +6 -6
@@ -19,74 +19,57 @@
19
19
  * - Value type consistency
20
20
  * - Weighted values non-empty and consistent
21
21
  */
22
- import { z } from "zod";
22
+ import { z } from 'zod';
23
23
  // ── Schemas (subset of app-quonfig config-schemas.ts) ───────────────────
24
- const ConfigTypeSchema = z.enum([
25
- "feature_flag",
26
- "config",
27
- "log_level",
28
- "segment",
29
- "schema",
30
- ]);
31
- const ValueTypeSchema = z.enum([
32
- "bool",
33
- "string",
34
- "int",
35
- "double",
36
- "json",
37
- "string_list",
38
- "duration",
39
- "log_level",
40
- ]);
24
+ const ConfigTypeSchema = z.enum(['feature_flag', 'config', 'log_level', 'segment', 'schema']);
25
+ const ValueTypeSchema = z.enum(['bool', 'string', 'int', 'double', 'json', 'string_list', 'duration', 'log_level']);
41
26
  const OperatorSchema = z.enum([
42
- "ALWAYS_TRUE",
43
- "PROP_IS_ONE_OF",
44
- "PROP_IS_NOT_ONE_OF",
45
- "PROP_STARTS_WITH_ONE_OF",
46
- "PROP_DOES_NOT_START_WITH_ONE_OF",
47
- "PROP_ENDS_WITH_ONE_OF",
48
- "PROP_DOES_NOT_END_WITH_ONE_OF",
49
- "PROP_CONTAINS_ONE_OF",
50
- "PROP_DOES_NOT_CONTAIN_ONE_OF",
51
- "PROP_LESS_THAN",
52
- "PROP_LESS_THAN_OR_EQUAL",
53
- "PROP_GREATER_THAN",
54
- "PROP_GREATER_THAN_OR_EQUAL",
55
- "PROP_BEFORE",
56
- "PROP_AFTER",
57
- "PROP_MATCHES",
58
- "PROP_DOES_NOT_MATCH",
59
- "IN_SEG",
60
- "NOT_IN_SEG",
61
- "IN_INT_RANGE",
62
- "LOOKUP_KEY_IN",
63
- "LOOKUP_KEY_NOT_IN",
27
+ 'ALWAYS_TRUE',
28
+ 'PROP_IS_ONE_OF',
29
+ 'PROP_IS_NOT_ONE_OF',
30
+ 'PROP_STARTS_WITH_ONE_OF',
31
+ 'PROP_DOES_NOT_START_WITH_ONE_OF',
32
+ 'PROP_ENDS_WITH_ONE_OF',
33
+ 'PROP_DOES_NOT_END_WITH_ONE_OF',
34
+ 'PROP_CONTAINS_ONE_OF',
35
+ 'PROP_DOES_NOT_CONTAIN_ONE_OF',
36
+ 'PROP_LESS_THAN',
37
+ 'PROP_LESS_THAN_OR_EQUAL',
38
+ 'PROP_GREATER_THAN',
39
+ 'PROP_GREATER_THAN_OR_EQUAL',
40
+ 'PROP_BEFORE',
41
+ 'PROP_AFTER',
42
+ 'PROP_MATCHES',
43
+ 'PROP_DOES_NOT_MATCH',
44
+ 'IN_SEG',
45
+ 'NOT_IN_SEG',
46
+ 'IN_INT_RANGE',
47
+ 'LOOKUP_KEY_IN',
48
+ 'LOOKUP_KEY_NOT_IN',
64
49
  ]);
65
- const LogLevelSchema = z.enum([
66
- "TRACE",
67
- "DEBUG",
68
- "INFO",
69
- "WARN",
70
- "ERROR",
71
- "FATAL",
72
- ]);
73
- const BoolValueSchema = z.object({ type: z.literal("bool"), value: z.boolean() });
74
- const StringValueSchema = z.object({ type: z.literal("string"), value: z.string() });
75
- const IntValueSchema = z.object({ type: z.literal("int"), value: z.union([z.number(), z.string()]) });
76
- const DoubleValueSchema = z.object({ type: z.literal("double"), value: z.union([z.number(), z.string()]) });
77
- const JsonValueSchema = z.object({ type: z.literal("json"), value: z.unknown() });
78
- const StringListValueSchema = z.object({ type: z.literal("string_list"), value: z.array(z.string()) });
79
- const DurationValueSchema = z.object({ type: z.literal("duration"), value: z.string() });
80
- const LogLevelValueSchema = z.object({ type: z.literal("log_level"), value: LogLevelSchema });
50
+ const LogLevelSchema = z.enum(['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL']);
51
+ const BoolValueSchema = z.object({ type: z.literal('bool'), value: z.boolean() });
52
+ const StringValueSchema = z.object({ type: z.literal('string'), value: z.string() });
53
+ const IntValueSchema = z.object({ type: z.literal('int'), value: z.union([z.number(), z.string()]) });
54
+ const DoubleValueSchema = z.object({ type: z.literal('double'), value: z.union([z.number(), z.string()]) });
55
+ const JsonValueSchema = z.object({
56
+ type: z.literal('json'),
57
+ value: z.any().refine((v) => typeof v !== 'string', {
58
+ message: 'json values must be native JSON (object/array/number/boolean/null). Stringified JSON is no longer allowed — use { "a": 1 } instead of "{\\"a\\":1}".',
59
+ }),
60
+ });
61
+ const StringListValueSchema = z.object({ type: z.literal('string_list'), value: z.array(z.string()) });
62
+ const DurationValueSchema = z.object({ type: z.literal('duration'), value: z.string() });
63
+ const LogLevelValueSchema = z.object({ type: z.literal('log_level'), value: LogLevelSchema });
81
64
  const SchemaValueSchema = z.object({
82
- type: z.literal("schema"),
65
+ type: z.literal('schema'),
83
66
  value: z.object({ schemaType: z.string(), schema: z.string() }),
84
67
  });
85
68
  const ProvidedValueSchema = z.object({
86
- type: z.literal("provided"),
69
+ type: z.literal('provided'),
87
70
  value: z.object({ source: z.string(), lookup: z.string() }),
88
71
  });
89
- const ValueSchema = z.discriminatedUnion("type", [
72
+ const ValueSchema = z.discriminatedUnion('type', [
90
73
  BoolValueSchema,
91
74
  StringValueSchema,
92
75
  IntValueSchema,
@@ -104,10 +87,10 @@ const WeightedValueSchema = z.object({
104
87
  weight: z.number().int().min(0).max(MAX_WEIGHT),
105
88
  });
106
89
  const WeightedValuesSchema = z.object({
107
- type: z.literal("weighted_values"),
90
+ type: z.literal('weighted_values'),
108
91
  value: z.object({
109
92
  weightedValues: z.array(WeightedValueSchema),
110
- hashByPropertyName: z.string().default("user.key"),
93
+ hashByPropertyName: z.string().default('user.key'),
111
94
  splitEvenly: z.boolean().optional(),
112
95
  }),
113
96
  });
@@ -122,8 +105,8 @@ const ConfigRuleSchema = z.object({
122
105
  value: RuleValueSchema,
123
106
  });
124
107
  /** Environment ID must be a slug: lowercase alphanumeric + dashes, not a UUID. */
125
- const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
126
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
108
+ const SLUG_RE = /^[\da-z]+(?:-[\da-z]+)*$/;
109
+ const UUID_RE = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i;
127
110
  const ConfigEnvironmentSchema = z.object({
128
111
  id: z.string(),
129
112
  rules: z.array(ConfigRuleSchema),
@@ -134,7 +117,8 @@ const VariantSchema = z.object({
134
117
  value: ValueSchema,
135
118
  description: z.string().optional(),
136
119
  });
137
- const StoredConfigSchema = z.object({
120
+ const StoredConfigSchema = z
121
+ .object({
138
122
  id: z.string().optional(),
139
123
  projectId: z.string().optional(),
140
124
  key: z.string(),
@@ -151,37 +135,48 @@ const StoredConfigSchema = z.object({
151
135
  }),
152
136
  environments: z.array(ConfigEnvironmentSchema).default([]),
153
137
  variants: z.array(VariantSchema).default([]),
154
- }).passthrough(); // Allow extra fields (tags, schemaUsageMode, etc.)
138
+ })
139
+ .passthrough(); // Allow extra fields (tags, schemaUsageMode, etc.)
155
140
  const SchemaDocumentSchema = z.object({}).passthrough();
156
141
  /** Maps config directory names to their expected config type. */
157
142
  const DIR_TO_TYPE = {
158
- "configs": "config",
159
- "feature-flags": "feature_flag",
160
- "segments": "segment",
161
- "log-levels": "log_level",
143
+ configs: 'config',
144
+ 'feature-flags': 'feature_flag',
145
+ segments: 'segment',
146
+ 'log-levels': 'log_level',
162
147
  };
163
148
  const CONFIG_DIRS = new Set(Object.keys(DIR_TO_TYPE));
164
- const SCHEMA_DIRS = new Set(["schemas", "schemas-protected"]);
149
+ const SCHEMA_DIRS = new Set(['schemas', 'schemas-protected']);
165
150
  const KNOWN_DIRS = new Set([...CONFIG_DIRS, ...SCHEMA_DIRS]);
166
151
  // Operators that reference segments
167
- const SEGMENT_OPERATORS = new Set(["IN_SEG", "NOT_IN_SEG"]);
152
+ const SEGMENT_OPERATORS = new Set(['IN_SEG', 'NOT_IN_SEG']);
168
153
  // Operators that require a propertyName
169
154
  const PROPERTY_OPERATORS = new Set([
170
- "PROP_IS_ONE_OF", "PROP_IS_NOT_ONE_OF",
171
- "PROP_STARTS_WITH_ONE_OF", "PROP_DOES_NOT_START_WITH_ONE_OF",
172
- "PROP_ENDS_WITH_ONE_OF", "PROP_DOES_NOT_END_WITH_ONE_OF",
173
- "PROP_CONTAINS_ONE_OF", "PROP_DOES_NOT_CONTAIN_ONE_OF",
174
- "PROP_LESS_THAN", "PROP_LESS_THAN_OR_EQUAL",
175
- "PROP_GREATER_THAN", "PROP_GREATER_THAN_OR_EQUAL",
176
- "PROP_BEFORE", "PROP_AFTER",
177
- "PROP_MATCHES", "PROP_DOES_NOT_MATCH",
155
+ 'PROP_IS_ONE_OF',
156
+ 'PROP_IS_NOT_ONE_OF',
157
+ 'PROP_STARTS_WITH_ONE_OF',
158
+ 'PROP_DOES_NOT_START_WITH_ONE_OF',
159
+ 'PROP_ENDS_WITH_ONE_OF',
160
+ 'PROP_DOES_NOT_END_WITH_ONE_OF',
161
+ 'PROP_CONTAINS_ONE_OF',
162
+ 'PROP_DOES_NOT_CONTAIN_ONE_OF',
163
+ 'PROP_LESS_THAN',
164
+ 'PROP_LESS_THAN_OR_EQUAL',
165
+ 'PROP_GREATER_THAN',
166
+ 'PROP_GREATER_THAN_OR_EQUAL',
167
+ 'PROP_BEFORE',
168
+ 'PROP_AFTER',
169
+ 'PROP_MATCHES',
170
+ 'PROP_DOES_NOT_MATCH',
178
171
  ]);
179
172
  // Operators that require a valueToMatch
180
173
  const VALUE_REQUIRED_OPERATORS = new Set([
181
174
  ...PROPERTY_OPERATORS,
182
- "IN_SEG", "NOT_IN_SEG",
183
- "IN_INT_RANGE",
184
- "LOOKUP_KEY_IN", "LOOKUP_KEY_NOT_IN",
175
+ 'IN_SEG',
176
+ 'NOT_IN_SEG',
177
+ 'IN_INT_RANGE',
178
+ 'LOOKUP_KEY_IN',
179
+ 'LOOKUP_KEY_NOT_IN',
185
180
  ]);
186
181
  /**
187
182
  * Reject `sendToClientSdk` on feature_flag rows — the field is config-only.
@@ -189,21 +184,21 @@ const VALUE_REQUIRED_OPERATORS = new Set([
189
184
  * (true or false). Segments are handled separately on the parsed config.
190
185
  */
191
186
  function checkFeatureFlagForbiddenFields(parsed, file, issues) {
192
- if (typeof parsed === "object" &&
187
+ if (typeof parsed === 'object' &&
193
188
  parsed !== null &&
194
- parsed.type === "feature_flag" &&
195
- Object.prototype.hasOwnProperty.call(parsed, "sendToClientSdk")) {
189
+ parsed.type === 'feature_flag' &&
190
+ Object.hasOwn(parsed, 'sendToClientSdk')) {
196
191
  issues.push({
197
192
  file,
198
193
  message: `feature_flag must not set "sendToClientSdk" — this field is only valid on config rows`,
199
- severity: "error",
194
+ severity: 'error',
200
195
  suggestion: `Remove the "sendToClientSdk" field from this feature_flag JSON`,
201
196
  });
202
197
  }
203
198
  }
204
199
  // ── Validation from filesystem ──────────────────────────────────────────
205
- import * as fs from "node:fs";
206
- import * as path from "node:path";
200
+ import * as fs from 'node:fs';
201
+ import * as path from 'node:path';
207
202
  /**
208
203
  * Validate an entire workspace directory on disk.
209
204
  */
@@ -234,80 +229,86 @@ export function validateWorkspace(workspaceDir) {
234
229
  try {
235
230
  entries = fs.readdirSync(workspaceDir, { withFileTypes: true });
236
231
  }
237
- catch (err) {
232
+ catch (error) {
238
233
  return {
239
- issues: [{
234
+ issues: [
235
+ {
240
236
  file: workspaceDir,
241
- message: `Cannot read workspace directory: ${err.message}`,
242
- severity: "error",
243
- }],
237
+ message: `Cannot read workspace directory: ${error.message}`,
238
+ severity: 'error',
239
+ },
240
+ ],
244
241
  filesChecked: 0,
245
242
  valid: false,
246
243
  stats,
247
244
  };
248
245
  }
249
246
  for (const entry of entries) {
250
- if (entry.name.startsWith("."))
247
+ if (entry.name.startsWith('.'))
251
248
  continue; // skip .git, .qf, etc.
252
249
  if (entry.isDirectory() && !KNOWN_DIRS.has(entry.name)) {
253
250
  issues.push({
254
251
  file: entry.name,
255
252
  message: `Unexpected directory "${entry.name}"`,
256
- severity: "warning",
257
- suggestion: `Expected directories: ${[...KNOWN_DIRS].join(", ")}`,
253
+ severity: 'warning',
254
+ suggestion: `Expected directories: ${[...KNOWN_DIRS].join(', ')}`,
258
255
  });
259
256
  }
260
257
  }
261
258
  // Validate quonfig.json at workspace root
262
- const quonfigPath = path.join(workspaceDir, "quonfig.json");
263
- if (!fs.existsSync(quonfigPath)) {
264
- issues.push({
265
- file: "quonfig.json",
266
- message: `quonfig.json is missing from workspace root`,
267
- severity: "error",
268
- suggestion: `Create quonfig.json with format: {"environments": ["production", "staging"]}`,
269
- });
270
- }
271
- else {
259
+ const quonfigPath = path.join(workspaceDir, 'quonfig.json');
260
+ if (fs.existsSync(quonfigPath)) {
272
261
  let quonfigRaw;
273
262
  try {
274
- quonfigRaw = fs.readFileSync(quonfigPath, "utf-8");
263
+ quonfigRaw = fs.readFileSync(quonfigPath, 'utf8');
275
264
  }
276
- catch (err) {
277
- quonfigRaw = "";
278
- issues.push({ file: "quonfig.json", message: `Cannot read quonfig.json: ${err.message}`, severity: "error" });
265
+ catch (error) {
266
+ quonfigRaw = '';
267
+ issues.push({
268
+ file: 'quonfig.json',
269
+ message: `Cannot read quonfig.json: ${error.message}`,
270
+ severity: 'error',
271
+ });
279
272
  }
280
273
  if (quonfigRaw) {
281
274
  let quonfigParsed;
282
275
  try {
283
276
  quonfigParsed = JSON.parse(quonfigRaw);
284
277
  }
285
- catch (err) {
278
+ catch (error) {
286
279
  quonfigParsed = null;
287
- issues.push({ file: "quonfig.json", message: `Invalid JSON: ${err.message}`, severity: "error" });
280
+ issues.push({ file: 'quonfig.json', message: `Invalid JSON: ${error.message}`, severity: 'error' });
288
281
  }
289
282
  const quonfigResult = z.object({ environments: z.array(z.string()) }).safeParse(quonfigParsed);
290
- if (!quonfigResult.success) {
283
+ if (quonfigResult.success) {
284
+ for (const envId of quonfigResult.data.environments) {
285
+ declaredEnvIds.add(envId);
286
+ }
287
+ }
288
+ else {
291
289
  issues.push({
292
- file: "quonfig.json",
290
+ file: 'quonfig.json',
293
291
  message: `quonfig.json must have an "environments" array of strings`,
294
- severity: "error",
292
+ severity: 'error',
295
293
  suggestion: `Format: {"environments": ["production", "staging"]}`,
296
294
  });
297
295
  }
298
- else {
299
- for (const envId of quonfigResult.data.environments) {
300
- declaredEnvIds.add(envId);
301
- }
302
- }
303
296
  }
304
297
  }
298
+ else {
299
+ issues.push({
300
+ file: 'quonfig.json',
301
+ message: `quonfig.json is missing from workspace root`,
302
+ severity: 'error',
303
+ suggestion: `Create quonfig.json with format: {"environments": ["production", "staging"]}`,
304
+ });
305
+ }
305
306
  // First pass: parse all files and collect keys
306
307
  for (const dir of KNOWN_DIRS) {
307
308
  const dirPath = path.join(workspaceDir, dir);
308
309
  if (!fs.existsSync(dirPath))
309
310
  continue;
310
- const files = fs.readdirSync(dirPath).filter(f => f.endsWith(".json") && !f.startsWith("."));
311
+ const files = fs.readdirSync(dirPath).filter((f) => f.endsWith('.json') && !f.startsWith('.'));
311
312
  for (const file of files) {
312
313
  const filePath = path.join(dirPath, file);
313
314
  const relPath = `${dir}/${file}`;
@@ -315,21 +316,21 @@ export function validateWorkspace(workspaceDir) {
315
316
  // Read and parse JSON
316
317
  let raw;
317
318
  try {
318
- raw = fs.readFileSync(filePath, "utf-8");
319
+ raw = fs.readFileSync(filePath, 'utf8');
319
320
  }
320
- catch (err) {
321
- issues.push({ file: relPath, message: `Cannot read file: ${err.message}`, severity: "error" });
321
+ catch (error) {
322
+ issues.push({ file: relPath, message: `Cannot read file: ${error.message}`, severity: 'error' });
322
323
  continue;
323
324
  }
324
325
  let parsed;
325
326
  try {
326
327
  parsed = JSON.parse(raw);
327
328
  }
328
- catch (err) {
329
+ catch (error) {
329
330
  issues.push({
330
331
  file: relPath,
331
- message: `Invalid JSON: ${err.message}`,
332
- severity: "error",
332
+ message: `Invalid JSON: ${error.message}`,
333
+ severity: 'error',
333
334
  });
334
335
  continue;
335
336
  }
@@ -339,13 +340,13 @@ export function validateWorkspace(workspaceDir) {
339
340
  for (const issue of result.error.issues) {
340
341
  issues.push({
341
342
  file: relPath,
342
- message: `Schema: ${issue.path.join(".")} - ${issue.message}`,
343
- severity: "error",
343
+ message: `Schema: ${issue.path.join('.')} - ${issue.message}`,
344
+ severity: 'error',
344
345
  });
345
346
  }
346
347
  continue;
347
348
  }
348
- const schemaKey = file.replace(/\.json$/, "");
349
+ const schemaKey = file.replace(/\.json$/, '');
349
350
  schemaKeys.add(schemaKey);
350
351
  validateKey(schemaKey, relPath, issues);
351
352
  allSchemaFiles.push({ key: schemaKey, file: relPath });
@@ -359,20 +360,20 @@ export function validateWorkspace(workspaceDir) {
359
360
  for (const issue of result.error.issues) {
360
361
  issues.push({
361
362
  file: relPath,
362
- message: `Schema: ${issue.path.join(".")} - ${issue.message}`,
363
- severity: "error",
363
+ message: `Schema: ${issue.path.join('.')} - ${issue.message}`,
364
+ severity: 'error',
364
365
  });
365
366
  }
366
367
  continue;
367
368
  }
368
369
  const config = result.data;
369
- const expectedKey = file.replace(/\.json$/, "");
370
+ const expectedKey = file.replace(/\.json$/, '');
370
371
  // Key matches filename
371
372
  if (config.key !== expectedKey) {
372
373
  issues.push({
373
374
  file: relPath,
374
375
  message: `Key "${config.key}" does not match filename "${expectedKey}"`,
375
- severity: "error",
376
+ severity: 'error',
376
377
  suggestion: `Rename file to "${config.key}.json" or set key to "${expectedKey}"`,
377
378
  });
378
379
  }
@@ -384,41 +385,41 @@ export function validateWorkspace(workspaceDir) {
384
385
  issues.push({
385
386
  file: relPath,
386
387
  message: `Type "${config.type}" in directory "${dir}" (expected "${expectedType}")`,
387
- severity: "error",
388
+ severity: 'error',
388
389
  suggestion: `Move to "${typeToDir(config.type)}/" or change type to "${expectedType}"`,
389
390
  });
390
391
  }
391
392
  // Type-specific constraints
392
- if (config.type === "segment") {
393
- if (config.valueType !== "bool") {
393
+ if (config.type === 'segment') {
394
+ if (config.valueType !== 'bool') {
394
395
  issues.push({
395
396
  file: relPath,
396
397
  message: `Segment must have valueType "bool", got "${config.valueType}"`,
397
- severity: "error",
398
+ severity: 'error',
398
399
  });
399
400
  }
400
401
  if (config.sendToClientSdk) {
401
402
  issues.push({
402
403
  file: relPath,
403
404
  message: `Segment must have sendToClientSdk=false`,
404
- severity: "error",
405
+ severity: 'error',
405
406
  });
406
407
  }
407
408
  segmentKeys.add(config.key);
408
409
  }
409
- if (config.type === "log_level") {
410
- if (config.valueType !== "log_level") {
410
+ if (config.type === 'log_level') {
411
+ if (config.valueType !== 'log_level') {
411
412
  issues.push({
412
413
  file: relPath,
413
414
  message: `Log level must have valueType "log_level", got "${config.valueType}"`,
414
- severity: "error",
415
+ severity: 'error',
415
416
  });
416
417
  }
417
- if (!config.key.startsWith("log-level.")) {
418
+ if (!config.key.startsWith('log-level.')) {
418
419
  issues.push({
419
420
  file: relPath,
420
421
  message: `Log level key "${config.key}" must start with "log-level."`,
421
- severity: "error",
422
+ severity: 'error',
422
423
  suggestion: `Rename to "log-level.${config.key}" (and rename the file to "log-level.${config.key}.json")`,
423
424
  });
424
425
  }
@@ -426,22 +427,22 @@ export function validateWorkspace(workspaceDir) {
426
427
  // Validate environment IDs are slugs, not UUIDs
427
428
  validateEnvironmentIds(config.environments, relPath, issues);
428
429
  // Validate rules
429
- validateRules(config.default.rules, relPath, "default", config.valueType, issues, config.variants);
430
+ validateRules(config.default.rules, relPath, 'default', config.valueType, issues, config.variants);
430
431
  for (const env of config.environments) {
431
432
  validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues, config.variants);
432
433
  }
433
434
  // Count by type
434
435
  switch (config.type) {
435
- case "config":
436
+ case 'config':
436
437
  stats.configs++;
437
438
  break;
438
- case "feature_flag":
439
+ case 'feature_flag':
439
440
  stats.featureFlags++;
440
441
  break;
441
- case "segment":
442
+ case 'segment':
442
443
  stats.segments++;
443
444
  break;
444
- case "log_level":
445
+ case 'log_level':
445
446
  stats.logLevels++;
446
447
  break;
447
448
  }
@@ -460,7 +461,7 @@ export function validateWorkspace(workspaceDir) {
460
461
  const dirPath = path.join(workspaceDir, dir);
461
462
  if (!fs.existsSync(dirPath))
462
463
  continue;
463
- const files = fs.readdirSync(dirPath).filter(f => f.endsWith(".json") && !f.startsWith("."));
464
+ const files = fs.readdirSync(dirPath).filter((f) => f.endsWith('.json') && !f.startsWith('.'));
464
465
  for (const file of files) {
465
466
  const filePath = path.join(dirPath, file);
466
467
  const relPath = `${dir}/${file}`;
@@ -469,7 +470,7 @@ export function validateWorkspace(workspaceDir) {
469
470
  }
470
471
  let raw;
471
472
  try {
472
- raw = fs.readFileSync(filePath, "utf-8");
473
+ raw = fs.readFileSync(filePath, 'utf8');
473
474
  }
474
475
  catch {
475
476
  continue;
@@ -493,7 +494,7 @@ export function validateWorkspace(workspaceDir) {
493
494
  issues.push({
494
495
  file: relPath,
495
496
  message: `References segment "${ref}" which does not exist`,
496
- severity: "error",
497
+ severity: 'error',
497
498
  suggestion: `Create segments/${ref}.json or remove the segment reference`,
498
499
  });
499
500
  }
@@ -505,7 +506,7 @@ export function validateWorkspace(workspaceDir) {
505
506
  issues.push({
506
507
  file: relPath,
507
508
  message: `References schema "${config.schemaKey}" which does not exist`,
508
- severity: "error",
509
+ severity: 'error',
509
510
  suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
510
511
  });
511
512
  }
@@ -518,7 +519,7 @@ export function validateWorkspace(workspaceDir) {
518
519
  issues.push({
519
520
  file: relPath,
520
521
  message: `References environment "${env.id}" which is not declared in quonfig.json`,
521
- severity: "error",
522
+ severity: 'error',
522
523
  suggestion: `Add "${env.id}" to the environments array in quonfig.json, or remove this environment override`,
523
524
  });
524
525
  }
@@ -536,9 +537,9 @@ export function validateWorkspace(workspaceDir) {
536
537
  for (const [key, files] of keyToFiles) {
537
538
  if (files.length > 1) {
538
539
  issues.push({
539
- file: files.join(", "),
540
+ file: files.join(', '),
540
541
  message: `Duplicate key "${key}" found in multiple files`,
541
- severity: "error",
542
+ severity: 'error',
542
543
  suggestion: `Each key must be unique across the workspace`,
543
544
  });
544
545
  }
@@ -552,23 +553,31 @@ export function validateWorkspace(workspaceDir) {
552
553
  for (const [key, files] of schemaKeyToFiles) {
553
554
  if (files.length > 1) {
554
555
  issues.push({
555
- file: files.join(", "),
556
+ file: files.join(', '),
556
557
  message: `Duplicate schema key "${key}" found in multiple files`,
557
- severity: "error",
558
+ severity: 'error',
558
559
  suggestion: `Each schema key must be unique across schema directories`,
559
560
  });
560
561
  }
561
562
  }
562
563
  stats.uniqueKeysVerified = keyToFiles.size + schemaKeyToFiles.size;
563
- const hasErrors = issues.some(i => i.severity === "error");
564
+ const hasErrors = issues.some((i) => i.severity === 'error');
564
565
  return { issues, filesChecked, valid: !hasErrors, stats };
565
566
  }
566
567
  // Default empty stats for validateFileMap (which doesn't track detailed stats)
567
568
  function emptyStats() {
568
569
  return {
569
- configs: 0, featureFlags: 0, segments: 0, logLevels: 0, schemas: 0,
570
- environmentOverrides: 0, rules: 0, segmentRefsChecked: 0,
571
- schemaRefsChecked: 0, envRefsChecked: 0, uniqueKeysVerified: 0,
570
+ configs: 0,
571
+ featureFlags: 0,
572
+ segments: 0,
573
+ logLevels: 0,
574
+ schemas: 0,
575
+ environmentOverrides: 0,
576
+ rules: 0,
577
+ segmentRefsChecked: 0,
578
+ schemaRefsChecked: 0,
579
+ envRefsChecked: 0,
580
+ uniqueKeysVerified: 0,
572
581
  };
573
582
  }
574
583
  // ── Validate from in-memory file map (for git hook) ─────────────────────
@@ -586,50 +595,50 @@ export function validateFileMap(files) {
586
595
  const parsedConfigs = [];
587
596
  const declaredEnvIds = new Set();
588
597
  // Validate quonfig.json if it's included in the file map (it may not be in every commit)
589
- const quonfigContent = files.get("quonfig.json");
598
+ const quonfigContent = files.get('quonfig.json');
590
599
  if (quonfigContent !== undefined) {
591
600
  let quonfigParsed;
592
601
  try {
593
602
  quonfigParsed = JSON.parse(quonfigContent);
594
603
  }
595
- catch (err) {
604
+ catch (error) {
596
605
  quonfigParsed = null;
597
- issues.push({ file: "quonfig.json", message: `Invalid JSON: ${err.message}`, severity: "error" });
606
+ issues.push({ file: 'quonfig.json', message: `Invalid JSON: ${error.message}`, severity: 'error' });
598
607
  }
599
608
  if (quonfigParsed !== null) {
600
609
  const quonfigResult = z.object({ environments: z.array(z.string()) }).safeParse(quonfigParsed);
601
- if (!quonfigResult.success) {
610
+ if (quonfigResult.success) {
611
+ for (const envId of quonfigResult.data.environments) {
612
+ declaredEnvIds.add(envId);
613
+ }
614
+ }
615
+ else {
602
616
  issues.push({
603
- file: "quonfig.json",
617
+ file: 'quonfig.json',
604
618
  message: `quonfig.json must have an "environments" array of strings`,
605
- severity: "error",
619
+ severity: 'error',
606
620
  suggestion: `Format: {"environments": ["production", "staging"]}`,
607
621
  });
608
622
  }
609
- else {
610
- for (const envId of quonfigResult.data.environments) {
611
- declaredEnvIds.add(envId);
612
- }
613
- }
614
623
  }
615
624
  }
616
625
  for (const [relPath, content] of files) {
617
- const parts = relPath.split("/");
626
+ const parts = relPath.split('/');
618
627
  if (parts.length < 2)
619
628
  continue;
620
629
  const dir = parts[0];
621
- const file = parts[parts.length - 1];
630
+ const file = parts.at(-1);
622
631
  if (!KNOWN_DIRS.has(dir))
623
632
  continue;
624
- if (!file.endsWith(".json") || file.startsWith("."))
633
+ if (!file || !file.endsWith('.json') || file.startsWith('.'))
625
634
  continue;
626
635
  filesChecked++;
627
636
  let parsed;
628
637
  try {
629
638
  parsed = JSON.parse(content);
630
639
  }
631
- catch (err) {
632
- issues.push({ file: relPath, message: `Invalid JSON: ${err.message}`, severity: "error" });
640
+ catch (error) {
641
+ issues.push({ file: relPath, message: `Invalid JSON: ${error.message}`, severity: 'error' });
633
642
  continue;
634
643
  }
635
644
  if (SCHEMA_DIRS.has(dir)) {
@@ -638,13 +647,13 @@ export function validateFileMap(files) {
638
647
  for (const issue of result.error.issues) {
639
648
  issues.push({
640
649
  file: relPath,
641
- message: `Schema: ${issue.path.join(".")} - ${issue.message}`,
642
- severity: "error",
650
+ message: `Schema: ${issue.path.join('.')} - ${issue.message}`,
651
+ severity: 'error',
643
652
  });
644
653
  }
645
654
  continue;
646
655
  }
647
- const schemaKey = file.replace(/\.json$/, "");
656
+ const schemaKey = file.replace(/\.json$/, '');
648
657
  schemaKeys.add(schemaKey);
649
658
  validateKey(schemaKey, relPath, issues);
650
659
  allSchemaFiles.push({ key: schemaKey, file: relPath });
@@ -656,19 +665,19 @@ export function validateFileMap(files) {
656
665
  for (const issue of result.error.issues) {
657
666
  issues.push({
658
667
  file: relPath,
659
- message: `Schema: ${issue.path.join(".")} - ${issue.message}`,
660
- severity: "error",
668
+ message: `Schema: ${issue.path.join('.')} - ${issue.message}`,
669
+ severity: 'error',
661
670
  });
662
671
  }
663
672
  continue;
664
673
  }
665
674
  const config = result.data;
666
- const expectedKey = file.replace(/\.json$/, "");
675
+ const expectedKey = file.replace(/\.json$/, '');
667
676
  if (config.key !== expectedKey) {
668
677
  issues.push({
669
678
  file: relPath,
670
679
  message: `Key "${config.key}" does not match filename "${expectedKey}"`,
671
- severity: "error",
680
+ severity: 'error',
672
681
  suggestion: `Rename file to "${config.key}.json" or set key to "${expectedKey}"`,
673
682
  });
674
683
  }
@@ -678,34 +687,42 @@ export function validateFileMap(files) {
678
687
  issues.push({
679
688
  file: relPath,
680
689
  message: `Type "${config.type}" in directory "${dir}" (expected "${expectedType}")`,
681
- severity: "error",
690
+ severity: 'error',
682
691
  suggestion: `Move to "${typeToDir(config.type)}/" or change type to "${expectedType}"`,
683
692
  });
684
693
  }
685
- if (config.type === "segment") {
686
- if (config.valueType !== "bool") {
687
- issues.push({ file: relPath, message: `Segment must have valueType "bool", got "${config.valueType}"`, severity: "error" });
694
+ if (config.type === 'segment') {
695
+ if (config.valueType !== 'bool') {
696
+ issues.push({
697
+ file: relPath,
698
+ message: `Segment must have valueType "bool", got "${config.valueType}"`,
699
+ severity: 'error',
700
+ });
688
701
  }
689
702
  if (config.sendToClientSdk) {
690
- issues.push({ file: relPath, message: `Segment must have sendToClientSdk=false`, severity: "error" });
703
+ issues.push({ file: relPath, message: `Segment must have sendToClientSdk=false`, severity: 'error' });
691
704
  }
692
705
  segmentKeys.add(config.key);
693
706
  }
694
- if (config.type === "log_level") {
695
- if (config.valueType !== "log_level") {
696
- issues.push({ file: relPath, message: `Log level must have valueType "log_level", got "${config.valueType}"`, severity: "error" });
707
+ if (config.type === 'log_level') {
708
+ if (config.valueType !== 'log_level') {
709
+ issues.push({
710
+ file: relPath,
711
+ message: `Log level must have valueType "log_level", got "${config.valueType}"`,
712
+ severity: 'error',
713
+ });
697
714
  }
698
- if (!config.key.startsWith("log-level.")) {
715
+ if (!config.key.startsWith('log-level.')) {
699
716
  issues.push({
700
717
  file: relPath,
701
718
  message: `Log level key "${config.key}" must start with "log-level."`,
702
- severity: "error",
719
+ severity: 'error',
703
720
  suggestion: `Rename to "log-level.${config.key}" (and rename the file to "log-level.${config.key}.json")`,
704
721
  });
705
722
  }
706
723
  }
707
724
  validateEnvironmentIds(config.environments, relPath, issues);
708
- validateRules(config.default.rules, relPath, "default", config.valueType, issues, config.variants);
725
+ validateRules(config.default.rules, relPath, 'default', config.valueType, issues, config.variants);
709
726
  for (const env of config.environments) {
710
727
  validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues, config.variants);
711
728
  }
@@ -720,7 +737,7 @@ export function validateFileMap(files) {
720
737
  issues.push({
721
738
  file: relPath,
722
739
  message: `References segment "${ref}" which does not exist`,
723
- severity: "error",
740
+ severity: 'error',
724
741
  suggestion: `Create segments/${ref}.json or remove the segment reference`,
725
742
  });
726
743
  }
@@ -729,7 +746,7 @@ export function validateFileMap(files) {
729
746
  issues.push({
730
747
  file: relPath,
731
748
  message: `References schema "${config.schemaKey}" which does not exist`,
732
- severity: "error",
749
+ severity: 'error',
733
750
  suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
734
751
  });
735
752
  }
@@ -740,7 +757,7 @@ export function validateFileMap(files) {
740
757
  issues.push({
741
758
  file: relPath,
742
759
  message: `References environment "${env.id}" which is not declared in quonfig.json`,
743
- severity: "error",
760
+ severity: 'error',
744
761
  suggestion: `Add "${env.id}" to the environments array in quonfig.json, or remove this environment override`,
745
762
  });
746
763
  }
@@ -757,9 +774,9 @@ export function validateFileMap(files) {
757
774
  for (const [key, fileList] of keyToFiles) {
758
775
  if (fileList.length > 1) {
759
776
  issues.push({
760
- file: fileList.join(", "),
777
+ file: fileList.join(', '),
761
778
  message: `Duplicate key "${key}" found in multiple files`,
762
- severity: "error",
779
+ severity: 'error',
763
780
  });
764
781
  }
765
782
  }
@@ -772,13 +789,13 @@ export function validateFileMap(files) {
772
789
  for (const [key, fileList] of schemaKeyToFiles) {
773
790
  if (fileList.length > 1) {
774
791
  issues.push({
775
- file: fileList.join(", "),
792
+ file: fileList.join(', '),
776
793
  message: `Duplicate schema key "${key}" found in multiple files`,
777
- severity: "error",
794
+ severity: 'error',
778
795
  });
779
796
  }
780
797
  }
781
- const hasErrors = issues.some(i => i.severity === "error");
798
+ const hasErrors = issues.some((i) => i.severity === 'error');
782
799
  return { issues, filesChecked, valid: !hasErrors, stats: emptyStats() };
783
800
  }
784
801
  // ── Helpers ─────────────────────────────────────────────────────────────
@@ -788,7 +805,7 @@ function validateEnvironmentIds(environments, file, issues) {
788
805
  issues.push({
789
806
  file,
790
807
  message: `Environment ID "${env.id}" is a UUID — must be a human-readable slug (e.g., "production", "my-ci-env")`,
791
- severity: "error",
808
+ severity: 'error',
792
809
  suggestion: `Replace the UUID with the environment's slugified name`,
793
810
  });
794
811
  }
@@ -796,7 +813,7 @@ function validateEnvironmentIds(environments, file, issues) {
796
813
  issues.push({
797
814
  file,
798
815
  message: `Environment ID "${env.id}" is not a valid slug (expected lowercase alphanumeric + dashes)`,
799
- severity: "error",
816
+ severity: 'error',
800
817
  suggestion: `Use a slugified name like "production" or "my-ci-env"`,
801
818
  });
802
819
  }
@@ -804,67 +821,64 @@ function validateEnvironmentIds(environments, file, issues) {
804
821
  }
805
822
  function validateKey(key, file, issues) {
806
823
  if (key.length === 0) {
807
- issues.push({ file, message: `Key is empty`, severity: "error" });
824
+ issues.push({ file, message: `Key is empty`, severity: 'error' });
808
825
  }
809
826
  if (key.length > 512) {
810
- issues.push({ file, message: `Key exceeds 512 characters (${key.length})`, severity: "error" });
827
+ issues.push({ file, message: `Key exceeds 512 characters (${key.length})`, severity: 'error' });
811
828
  }
812
- if (key === "new") {
813
- issues.push({ file, message: `Key cannot be "new" (reserved)`, severity: "error" });
829
+ if (key === 'new') {
830
+ issues.push({ file, message: `Key cannot be "new" (reserved)`, severity: 'error' });
814
831
  }
815
832
  if (/[/\\]/.test(key)) {
816
- issues.push({ file, message: `Key contains slash or backslash`, severity: "error" });
833
+ issues.push({ file, message: `Key contains slash or backslash`, severity: 'error' });
817
834
  }
818
835
  }
819
836
  function validateRules(rules, file, context, expectedValueType, issues, variants = []) {
820
837
  if (rules.length === 0) {
821
- issues.push({ file, message: `${context}: no rules defined`, severity: "warning" });
838
+ issues.push({ file, message: `${context}: no rules defined`, severity: 'warning' });
822
839
  }
823
840
  // Pre-compute serialized variant values for matching
824
- const variantValues = variants.map(v => JSON.stringify(v.value));
825
- for (let i = 0; i < rules.length; i++) {
826
- const rule = rules[i];
841
+ const variantValues = new Set(variants.map((v) => JSON.stringify(v.value)));
842
+ for (const [i, rule] of rules.entries()) {
827
843
  const ruleCtx = `${context}.rules[${i}]`;
828
844
  // Check value type consistency
829
845
  // Skip weighted_values (contains its own values), schema, and provided (special types)
830
- const SPECIAL_VALUE_TYPES = new Set(["weighted_values", "schema", "provided"]);
846
+ const SPECIAL_VALUE_TYPES = new Set(['weighted_values', 'schema', 'provided']);
831
847
  if (!SPECIAL_VALUE_TYPES.has(rule.value.type)) {
832
848
  if (rule.value.type !== expectedValueType) {
833
849
  issues.push({
834
850
  file,
835
851
  message: `${ruleCtx}: value type "${rule.value.type}" does not match config valueType "${expectedValueType}"`,
836
- severity: "error",
852
+ severity: 'error',
837
853
  });
838
854
  }
839
855
  // Check that value matches one of the defined variants
840
856
  if (variants.length > 0) {
841
857
  const serialized = JSON.stringify(rule.value);
842
- if (!variantValues.includes(serialized)) {
858
+ if (!variantValues.has(serialized)) {
843
859
  issues.push({
844
860
  file,
845
861
  message: `${ruleCtx}: value does not match any defined variant`,
846
- severity: "error",
862
+ severity: 'error',
847
863
  suggestion: `Rule value must be one of the defined variants`,
848
864
  });
849
865
  }
850
866
  }
851
867
  }
852
- else if (rule.value.type === "weighted_values") {
868
+ else if (rule.value.type === 'weighted_values') {
853
869
  // Validate weighted values
854
870
  const wv = rule.value.value;
855
871
  if (wv.weightedValues.length === 0) {
856
- issues.push({ file, message: `${ruleCtx}: weighted values list is empty`, severity: "error" });
872
+ issues.push({ file, message: `${ruleCtx}: weighted values list is empty`, severity: 'error' });
857
873
  }
858
874
  // Percentage rollouts must reference named variants — the UI cannot edit
859
875
  // a rollout when variants is empty. Bool configs are exempt because the
860
876
  // UI supplies implicit true/false variants.
861
- if (wv.weightedValues.length > 0 &&
862
- variants.length === 0 &&
863
- expectedValueType !== "bool") {
877
+ if (wv.weightedValues.length > 0 && variants.length === 0 && expectedValueType !== 'bool') {
864
878
  issues.push({
865
879
  file,
866
880
  message: `${ruleCtx}: weighted_values rollout requires at least one variant, but variants is empty`,
867
- severity: "error",
881
+ severity: 'error',
868
882
  suggestion: `Define variants for the possible rollout values, then reference them from weightedValues.`,
869
883
  });
870
884
  }
@@ -873,17 +887,17 @@ function validateRules(rules, file, context, expectedValueType, issues, variants
873
887
  issues.push({
874
888
  file,
875
889
  message: `${ruleCtx}.weightedValues[${j}]: value type "${wv.weightedValues[j].value.type}" does not match config valueType "${expectedValueType}"`,
876
- severity: "error",
890
+ severity: 'error',
877
891
  });
878
892
  }
879
893
  // Check that each weighted value matches one of the defined variants
880
894
  if (variants.length > 0) {
881
895
  const serialized = JSON.stringify(wv.weightedValues[j].value);
882
- if (!variantValues.includes(serialized)) {
896
+ if (!variantValues.has(serialized)) {
883
897
  issues.push({
884
898
  file,
885
899
  message: `${ruleCtx}.weightedValues[${j}]: value does not match any defined variant`,
886
- severity: "error",
900
+ severity: 'error',
887
901
  suggestion: `Weighted value must be one of the defined variants`,
888
902
  });
889
903
  }
@@ -898,14 +912,14 @@ function validateRules(rules, file, context, expectedValueType, issues, variants
898
912
  issues.push({
899
913
  file,
900
914
  message: `${critCtx}: operator "${criterion.operator}" requires propertyName`,
901
- severity: "error",
915
+ severity: 'error',
902
916
  });
903
917
  }
904
918
  if (VALUE_REQUIRED_OPERATORS.has(criterion.operator) && !criterion.valueToMatch) {
905
919
  issues.push({
906
920
  file,
907
921
  message: `${critCtx}: operator "${criterion.operator}" requires valueToMatch`,
908
- severity: "error",
922
+ severity: 'error',
909
923
  });
910
924
  }
911
925
  }
@@ -919,10 +933,10 @@ function collectSegmentReferences(config) {
919
933
  if (SEGMENT_OPERATORS.has(criterion.operator) && criterion.valueToMatch) {
920
934
  // The segment key is stored in the valueToMatch string value
921
935
  const val = criterion.valueToMatch;
922
- if (val.type === "string") {
936
+ if (val.type === 'string') {
923
937
  refs.push(val.value);
924
938
  }
925
- else if (val.type === "string_list") {
939
+ else if (val.type === 'string_list') {
926
940
  refs.push(...val.value);
927
941
  }
928
942
  }
@@ -950,8 +964,8 @@ export function formatResult(result) {
950
964
  lines.push(`OK: ${result.filesChecked} files checked, no errors found.\n`);
951
965
  }
952
966
  else {
953
- const errors = result.issues.filter(i => i.severity === "error");
954
- const warnings = result.issues.filter(i => i.severity === "warning");
967
+ const errors = result.issues.filter((i) => i.severity === 'error');
968
+ const warnings = result.issues.filter((i) => i.severity === 'warning');
955
969
  lines.push(`FAILED: ${errors.length} error(s), ${warnings.length} warning(s) in ${result.filesChecked} files.\n`);
956
970
  }
957
971
  // Show what was checked
@@ -967,13 +981,10 @@ export function formatResult(result) {
967
981
  if (s.schemas > 0)
968
982
  fileCounts.push(`${s.schemas} schemas`);
969
983
  if (fileCounts.length > 0) {
970
- lines.push(` Files: ${fileCounts.join(", ")}`);
984
+ lines.push(` Files: ${fileCounts.join(', ')}`);
971
985
  }
972
986
  const checks = [];
973
- checks.push(`${result.filesChecked} JSON parsed`);
974
- checks.push(`${result.filesChecked} schema-validated`);
975
- checks.push(`${s.uniqueKeysVerified} unique keys`);
976
- checks.push(`${s.rules} rules checked`);
987
+ checks.push(`${result.filesChecked} JSON parsed`, `${result.filesChecked} schema-validated`, `${s.uniqueKeysVerified} unique keys`, `${s.rules} rules checked`);
977
988
  if (s.environmentOverrides > 0)
978
989
  checks.push(`${s.environmentOverrides} env overrides`);
979
990
  if (s.segmentRefsChecked > 0)
@@ -982,7 +993,7 @@ export function formatResult(result) {
982
993
  checks.push(`${s.schemaRefsChecked} schema refs`);
983
994
  if (s.envRefsChecked > 0)
984
995
  checks.push(`${s.envRefsChecked} env refs`);
985
- lines.push(` Checks: ${checks.join(", ")}`);
996
+ lines.push(` Checks: ${checks.join(', ')}`);
986
997
  // Group issues by file
987
998
  const byFile = new Map();
988
999
  for (const issue of result.issues) {
@@ -991,24 +1002,24 @@ export function formatResult(result) {
991
1002
  byFile.set(issue.file, existing);
992
1003
  }
993
1004
  if (byFile.size > 0) {
994
- lines.push("");
1005
+ lines.push('');
995
1006
  }
996
1007
  for (const [file, fileIssues] of byFile) {
997
1008
  lines.push(` ${file}:`);
998
1009
  for (const issue of fileIssues) {
999
- const prefix = issue.severity === "error" ? "ERROR" : "WARN ";
1010
+ const prefix = issue.severity === 'error' ? 'ERROR' : 'WARN ';
1000
1011
  lines.push(` ${prefix}: ${issue.message}`);
1001
1012
  if (issue.suggestion) {
1002
1013
  lines.push(` -> ${issue.suggestion}`);
1003
1014
  }
1004
1015
  }
1005
- lines.push("");
1016
+ lines.push('');
1006
1017
  }
1007
1018
  if (result.issues.length > 0) {
1008
- const warnings = result.issues.filter(i => i.severity === "warning");
1019
+ const warnings = result.issues.filter((i) => i.severity === 'warning');
1009
1020
  if (warnings.length > 0 && result.valid) {
1010
1021
  lines.push(`\n${warnings.length} warning(s) found.`);
1011
1022
  }
1012
1023
  }
1013
- return lines.join("\n");
1024
+ return lines.join('\n');
1014
1025
  }