@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.
- package/README.md +214 -30
- 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.d.ts +12 -0
- package/dist/commands/migrate/doctor.js +63 -0
- package/dist/commands/migrate/my-code.d.ts +11 -0
- package/dist/commands/migrate/my-code.js +80 -0
- package/dist/commands/migrate/status.d.ts +10 -0
- package/dist/commands/migrate/status.js +99 -0
- package/dist/commands/migrate.d.ts +20 -0
- package/dist/commands/migrate.js +277 -0
- 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 +38 -41
- 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.d.ts +27 -0
- package/dist/migrate/doctor.js +222 -0
- package/dist/migrate/identifier-map.d.ts +14 -0
- package/dist/migrate/identifier-map.js +55 -0
- package/dist/migrate/import-state.d.ts +14 -0
- package/dist/migrate/import-state.js +60 -0
- package/dist/migrate/local-write.d.ts +26 -0
- package/dist/migrate/local-write.js +85 -0
- package/dist/migrate/migration-report.d.ts +42 -0
- package/dist/migrate/migration-report.js +85 -0
- package/dist/migrate/push-strategy.d.ts +34 -0
- package/dist/migrate/push-strategy.js +127 -0
- package/dist/migrate/push-to-cloud.d.ts +15 -0
- package/dist/migrate/push-to-cloud.js +31 -0
- package/dist/migrate/registry.d.ts +6 -0
- package/dist/migrate/registry.js +25 -0
- package/dist/migrate/source.d.ts +22 -0
- package/dist/migrate/source.js +12 -0
- package/dist/migrate/sources/flagsmith.d.ts +2 -0
- package/dist/migrate/sources/flagsmith.js +28 -0
- package/dist/migrate/sources/launch/api.d.ts +7 -0
- package/dist/migrate/sources/launch/api.js +75 -0
- package/dist/migrate/sources/launch/translate.d.ts +6 -0
- package/dist/migrate/sources/launch/translate.js +190 -0
- package/dist/migrate/sources/launch/types.d.ts +68 -0
- package/dist/migrate/sources/launch/types.js +1 -0
- package/dist/migrate/sources/launch.d.ts +3 -0
- package/dist/migrate/sources/launch.js +70 -0
- package/dist/migrate/sources/launchdarkly.d.ts +2 -0
- package/dist/migrate/sources/launchdarkly.js +18 -0
- package/dist/quonfig.d.ts +1 -1
- package/dist/quonfig.js +5 -9
- package/dist/ui/get-confirmation.d.ts +2 -1
- package/dist/ui/get-confirmation.js +9 -1
- package/dist/ui/get-value.d.ts +1 -1
- package/dist/ui/get-value.js +4 -3
- 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/is-interactive.d.ts +21 -1
- package/dist/util/is-interactive.js +21 -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 +256 -245
- package/oclif.manifest.json +365 -3
- package/package.json +6 -6
package/dist/verify/validate.js
CHANGED
|
@@ -19,74 +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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
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(
|
|
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(
|
|
69
|
+
type: z.literal('provided'),
|
|
87
70
|
value: z.object({ source: z.string(), lookup: z.string() }),
|
|
88
71
|
});
|
|
89
|
-
const ValueSchema = z.discriminatedUnion(
|
|
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(
|
|
90
|
+
type: z.literal('weighted_values'),
|
|
108
91
|
value: z.object({
|
|
109
92
|
weightedValues: z.array(WeightedValueSchema),
|
|
110
|
-
hashByPropertyName: z.string().default(
|
|
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 = /^[
|
|
126
|
-
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;
|
|
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
|
|
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
|
-
})
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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([
|
|
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([
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 ===
|
|
187
|
+
if (typeof parsed === 'object' &&
|
|
193
188
|
parsed !== null &&
|
|
194
|
-
parsed.type ===
|
|
195
|
-
Object.
|
|
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:
|
|
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
|
|
206
|
-
import * as path from
|
|
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 (
|
|
232
|
+
catch (error) {
|
|
238
233
|
return {
|
|
239
|
-
issues: [
|
|
234
|
+
issues: [
|
|
235
|
+
{
|
|
240
236
|
file: workspaceDir,
|
|
241
|
-
message: `Cannot read workspace directory: ${
|
|
242
|
-
severity:
|
|
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:
|
|
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,
|
|
263
|
-
if (
|
|
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,
|
|
263
|
+
quonfigRaw = fs.readFileSync(quonfigPath, 'utf8');
|
|
275
264
|
}
|
|
276
|
-
catch (
|
|
277
|
-
quonfigRaw =
|
|
278
|
-
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
|
+
});
|
|
279
272
|
}
|
|
280
273
|
if (quonfigRaw) {
|
|
281
274
|
let quonfigParsed;
|
|
282
275
|
try {
|
|
283
276
|
quonfigParsed = JSON.parse(quonfigRaw);
|
|
284
277
|
}
|
|
285
|
-
catch (
|
|
278
|
+
catch (error) {
|
|
286
279
|
quonfigParsed = null;
|
|
287
|
-
issues.push({ file:
|
|
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 (
|
|
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:
|
|
290
|
+
file: 'quonfig.json',
|
|
293
291
|
message: `quonfig.json must have an "environments" array of strings`,
|
|
294
|
-
severity:
|
|
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(
|
|
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,
|
|
319
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
319
320
|
}
|
|
320
|
-
catch (
|
|
321
|
-
issues.push({ file: relPath, message: `Cannot read file: ${
|
|
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 (
|
|
329
|
+
catch (error) {
|
|
329
330
|
issues.push({
|
|
330
331
|
file: relPath,
|
|
331
|
-
message: `Invalid JSON: ${
|
|
332
|
-
severity:
|
|
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(
|
|
343
|
-
severity:
|
|
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(
|
|
363
|
-
severity:
|
|
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:
|
|
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:
|
|
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 ===
|
|
393
|
-
if (config.valueType !==
|
|
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:
|
|
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:
|
|
405
|
+
severity: 'error',
|
|
405
406
|
});
|
|
406
407
|
}
|
|
407
408
|
segmentKeys.add(config.key);
|
|
408
409
|
}
|
|
409
|
-
if (config.type ===
|
|
410
|
-
if (config.valueType !==
|
|
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:
|
|
415
|
+
severity: 'error',
|
|
415
416
|
});
|
|
416
417
|
}
|
|
417
|
-
if (!config.key.startsWith(
|
|
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:
|
|
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,
|
|
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
|
|
436
|
+
case 'config':
|
|
436
437
|
stats.configs++;
|
|
437
438
|
break;
|
|
438
|
-
case
|
|
439
|
+
case 'feature_flag':
|
|
439
440
|
stats.featureFlags++;
|
|
440
441
|
break;
|
|
441
|
-
case
|
|
442
|
+
case 'segment':
|
|
442
443
|
stats.segments++;
|
|
443
444
|
break;
|
|
444
|
-
case
|
|
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(
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 ===
|
|
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,
|
|
570
|
-
|
|
571
|
-
|
|
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(
|
|
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 (
|
|
604
|
+
catch (error) {
|
|
596
605
|
quonfigParsed = null;
|
|
597
|
-
issues.push({ file:
|
|
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 (
|
|
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:
|
|
617
|
+
file: 'quonfig.json',
|
|
604
618
|
message: `quonfig.json must have an "environments" array of strings`,
|
|
605
|
-
severity:
|
|
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
|
|
630
|
+
const file = parts.at(-1);
|
|
622
631
|
if (!KNOWN_DIRS.has(dir))
|
|
623
632
|
continue;
|
|
624
|
-
if (!file.endsWith(
|
|
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 (
|
|
632
|
-
issues.push({ file: relPath, message: `Invalid JSON: ${
|
|
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(
|
|
642
|
-
severity:
|
|
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(
|
|
660
|
-
severity:
|
|
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:
|
|
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:
|
|
690
|
+
severity: 'error',
|
|
682
691
|
suggestion: `Move to "${typeToDir(config.type)}/" or change type to "${expectedType}"`,
|
|
683
692
|
});
|
|
684
693
|
}
|
|
685
|
-
if (config.type ===
|
|
686
|
-
if (config.valueType !==
|
|
687
|
-
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
|
+
});
|
|
688
701
|
}
|
|
689
702
|
if (config.sendToClientSdk) {
|
|
690
|
-
issues.push({ file: relPath, message: `Segment must have sendToClientSdk=false`, severity:
|
|
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 ===
|
|
695
|
-
if (config.valueType !==
|
|
696
|
-
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
|
+
});
|
|
697
714
|
}
|
|
698
|
-
if (!config.key.startsWith(
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
794
|
+
severity: 'error',
|
|
778
795
|
});
|
|
779
796
|
}
|
|
780
797
|
}
|
|
781
|
-
const hasErrors = issues.some(i => i.severity ===
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
827
|
+
issues.push({ file, message: `Key exceeds 512 characters (${key.length})`, severity: 'error' });
|
|
811
828
|
}
|
|
812
|
-
if (key ===
|
|
813
|
-
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' });
|
|
814
831
|
}
|
|
815
832
|
if (/[/\\]/.test(key)) {
|
|
816
|
-
issues.push({ file, message: `Key contains slash or backslash`, severity:
|
|
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:
|
|
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 (
|
|
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([
|
|
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:
|
|
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.
|
|
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:
|
|
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 ===
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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:
|
|
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:
|
|
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:
|
|
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 ===
|
|
936
|
+
if (val.type === 'string') {
|
|
923
937
|
refs.push(val.value);
|
|
924
938
|
}
|
|
925
|
-
else if (val.type ===
|
|
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 ===
|
|
954
|
-
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');
|
|
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 ===
|
|
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 ===
|
|
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(
|
|
1024
|
+
return lines.join('\n');
|
|
1014
1025
|
}
|