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