@quonfig/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +707 -0
  3. package/bin/dev.cmd +3 -0
  4. package/bin/dev.js +9 -0
  5. package/bin/run.cmd +3 -0
  6. package/bin/run.js +8 -0
  7. package/dist/api/get-environments.d.ts +10 -0
  8. package/dist/api/get-environments.js +11 -0
  9. package/dist/codegen/code-generators/base-generator.d.ts +11 -0
  10. package/dist/codegen/code-generators/base-generator.js +8 -0
  11. package/dist/codegen/code-generators/base-typescript-generator.d.ts +17 -0
  12. package/dist/codegen/code-generators/base-typescript-generator.js +35 -0
  13. package/dist/codegen/code-generators/node-typescript-generator.d.ts +8 -0
  14. package/dist/codegen/code-generators/node-typescript-generator.js +96 -0
  15. package/dist/codegen/code-generators/react-typescript-generator.d.ts +11 -0
  16. package/dist/codegen/code-generators/react-typescript-generator.js +116 -0
  17. package/dist/codegen/config-downloader.d.ts +7 -0
  18. package/dist/codegen/config-downloader.js +26 -0
  19. package/dist/codegen/json-schema-to-zod.d.ts +3 -0
  20. package/dist/codegen/json-schema-to-zod.js +170 -0
  21. package/dist/codegen/language-mappers/json-to-zod-mapper.d.ts +4 -0
  22. package/dist/codegen/language-mappers/json-to-zod-mapper.js +55 -0
  23. package/dist/codegen/language-mappers/zod-base-mapper.d.ts +35 -0
  24. package/dist/codegen/language-mappers/zod-base-mapper.js +111 -0
  25. package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +25 -0
  26. package/dist/codegen/language-mappers/zod-to-string-mapper.js +87 -0
  27. package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +35 -0
  28. package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +133 -0
  29. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +36 -0
  30. package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +150 -0
  31. package/dist/codegen/mustache-extractor.d.ts +6 -0
  32. package/dist/codegen/mustache-extractor.js +83 -0
  33. package/dist/codegen/schema-evaluator.d.ts +37 -0
  34. package/dist/codegen/schema-evaluator.js +283 -0
  35. package/dist/codegen/schema-extractor.d.ts +24 -0
  36. package/dist/codegen/schema-extractor.js +265 -0
  37. package/dist/codegen/types.d.ts +44 -0
  38. package/dist/codegen/types.js +5 -0
  39. package/dist/codegen/zod-introspection.d.ts +85 -0
  40. package/dist/codegen/zod-introspection.js +205 -0
  41. package/dist/commands/create.d.ts +19 -0
  42. package/dist/commands/create.js +175 -0
  43. package/dist/commands/download.d.ts +13 -0
  44. package/dist/commands/download.js +63 -0
  45. package/dist/commands/generate-new-hex-key.d.ts +7 -0
  46. package/dist/commands/generate-new-hex-key.js +10 -0
  47. package/dist/commands/generate.d.ts +16 -0
  48. package/dist/commands/generate.js +220 -0
  49. package/dist/commands/get.d.ts +13 -0
  50. package/dist/commands/get.js +141 -0
  51. package/dist/commands/info.d.ts +21 -0
  52. package/dist/commands/info.js +464 -0
  53. package/dist/commands/interactive.d.ts +5 -0
  54. package/dist/commands/interactive.js +8 -0
  55. package/dist/commands/list.d.ts +13 -0
  56. package/dist/commands/list.js +61 -0
  57. package/dist/commands/login.d.ts +10 -0
  58. package/dist/commands/login.js +124 -0
  59. package/dist/commands/logout.d.ts +7 -0
  60. package/dist/commands/logout.js +12 -0
  61. package/dist/commands/mcp.d.ts +13 -0
  62. package/dist/commands/mcp.js +145 -0
  63. package/dist/commands/override.d.ts +17 -0
  64. package/dist/commands/override.js +170 -0
  65. package/dist/commands/profile.d.ts +7 -0
  66. package/dist/commands/profile.js +47 -0
  67. package/dist/commands/schema.d.ts +21 -0
  68. package/dist/commands/schema.js +168 -0
  69. package/dist/commands/serve.d.ts +13 -0
  70. package/dist/commands/serve.js +99 -0
  71. package/dist/commands/set-default.d.ts +20 -0
  72. package/dist/commands/set-default.js +273 -0
  73. package/dist/commands/verify.d.ts +12 -0
  74. package/dist/commands/verify.js +48 -0
  75. package/dist/commands/whoami.d.ts +7 -0
  76. package/dist/commands/whoami.js +52 -0
  77. package/dist/commands/workspace.d.ts +7 -0
  78. package/dist/commands/workspace.js +38 -0
  79. package/dist/constants.d.ts +2 -0
  80. package/dist/constants.js +2 -0
  81. package/dist/help.d.ts +4 -0
  82. package/dist/help.js +10 -0
  83. package/dist/index.d.ts +40 -0
  84. package/dist/index.js +112 -0
  85. package/dist/interactive-prompt.d.ts +2 -0
  86. package/dist/interactive-prompt.js +162 -0
  87. package/dist/quonfig.d.ts +15 -0
  88. package/dist/quonfig.js +75 -0
  89. package/dist/result.d.ts +35 -0
  90. package/dist/result.js +9 -0
  91. package/dist/services/mcp.d.ts +15 -0
  92. package/dist/services/mcp.js +59 -0
  93. package/dist/ui/get-confirmation.d.ts +10 -0
  94. package/dist/ui/get-confirmation.js +4 -0
  95. package/dist/ui/get-environment.d.ts +11 -0
  96. package/dist/ui/get-environment.js +42 -0
  97. package/dist/ui/get-key.d.ts +27 -0
  98. package/dist/ui/get-key.js +15 -0
  99. package/dist/ui/get-string.d.ts +5 -0
  100. package/dist/ui/get-string.js +8 -0
  101. package/dist/ui/get-value.d.ts +18 -0
  102. package/dist/ui/get-value.js +46 -0
  103. package/dist/util/autocomplete.d.ts +6 -0
  104. package/dist/util/autocomplete.js +25 -0
  105. package/dist/util/coerce.d.ts +7 -0
  106. package/dist/util/coerce.js +62 -0
  107. package/dist/util/color.d.ts +3 -0
  108. package/dist/util/color.js +4 -0
  109. package/dist/util/config-value-dto.d.ts +16 -0
  110. package/dist/util/config-value-dto.js +82 -0
  111. package/dist/util/context.d.ts +18 -0
  112. package/dist/util/context.js +10 -0
  113. package/dist/util/domain-urls.d.ts +3 -0
  114. package/dist/util/domain-urls.js +18 -0
  115. package/dist/util/encryption.d.ts +20 -0
  116. package/dist/util/encryption.js +153 -0
  117. package/dist/util/file-manager.d.ts +12 -0
  118. package/dist/util/file-manager.js +16 -0
  119. package/dist/util/get-client.d.ts +7 -0
  120. package/dist/util/get-client.js +127 -0
  121. package/dist/util/is-interactive.d.ts +5 -0
  122. package/dist/util/is-interactive.js +7 -0
  123. package/dist/util/json-maybe.d.ts +3 -0
  124. package/dist/util/json-maybe.js +14 -0
  125. package/dist/util/name-arg.d.ts +4 -0
  126. package/dist/util/name-arg.js +3 -0
  127. package/dist/util/oauth-client.d.ts +30 -0
  128. package/dist/util/oauth-client.js +99 -0
  129. package/dist/util/secret-flags.d.ts +13 -0
  130. package/dist/util/secret-flags.js +13 -0
  131. package/dist/util/token-storage.d.ts +25 -0
  132. package/dist/util/token-storage.js +90 -0
  133. package/dist/validations/value.d.ts +4 -0
  134. package/dist/validations/value.js +11 -0
  135. package/dist/verify/standalone.d.ts +14 -0
  136. package/dist/verify/standalone.js +133 -0
  137. package/dist/verify/validate.d.ts +43 -0
  138. package/dist/verify/validate.js +756 -0
  139. package/dist/version.d.ts +2 -0
  140. package/dist/version.js +2 -0
  141. package/oclif.manifest.json +1429 -0
  142. package/package.json +104 -0
@@ -0,0 +1,756 @@
1
+ /**
2
+ * Workspace validation logic.
3
+ *
4
+ * This module validates a Quonfig workspace directory against the StoredConfig
5
+ * schema. It can be used from both the oclif CLI (`qfg verify`) and the
6
+ * standalone compiled binary (git pre-receive hook).
7
+ *
8
+ * Validation checks:
9
+ * - Valid JSON
10
+ * - Schema compliance (StoredConfigSchema from Zod)
11
+ * - Key matches filename
12
+ * - Key constraints (1-512 chars, no slashes, not "new")
13
+ * - Config type matches directory
14
+ * - Segment constraints (valueType=bool, sendToClientSdk=false)
15
+ * - Log level constraints (valueType=log_level)
16
+ * - Referential integrity (IN_SEG/NOT_IN_SEG reference existing segments)
17
+ * - schemaKey references existing schemas
18
+ * - Rule structure (criteria + value present)
19
+ * - Value type consistency
20
+ * - Weighted values non-empty and consistent
21
+ */
22
+ import { z } from "zod";
23
+ // ── Schemas (subset of app-quonfig config-schemas.ts) ───────────────────
24
+ const ConfigTypeSchema = z.enum([
25
+ "feature_flag",
26
+ "config",
27
+ "log_level",
28
+ "segment",
29
+ "schema",
30
+ ]);
31
+ const ValueTypeSchema = z.enum([
32
+ "bool",
33
+ "string",
34
+ "int",
35
+ "double",
36
+ "json",
37
+ "string_list",
38
+ "duration",
39
+ "log_level",
40
+ ]);
41
+ const OperatorSchema = z.enum([
42
+ "ALWAYS_TRUE",
43
+ "PROP_IS_ONE_OF",
44
+ "PROP_IS_NOT_ONE_OF",
45
+ "PROP_STARTS_WITH_ONE_OF",
46
+ "PROP_DOES_NOT_START_WITH_ONE_OF",
47
+ "PROP_ENDS_WITH_ONE_OF",
48
+ "PROP_DOES_NOT_END_WITH_ONE_OF",
49
+ "PROP_CONTAINS_ONE_OF",
50
+ "PROP_DOES_NOT_CONTAIN_ONE_OF",
51
+ "PROP_LESS_THAN",
52
+ "PROP_LESS_THAN_OR_EQUAL",
53
+ "PROP_GREATER_THAN",
54
+ "PROP_GREATER_THAN_OR_EQUAL",
55
+ "PROP_BEFORE",
56
+ "PROP_AFTER",
57
+ "PROP_MATCHES",
58
+ "PROP_DOES_NOT_MATCH",
59
+ "IN_SEG",
60
+ "NOT_IN_SEG",
61
+ "IN_INT_RANGE",
62
+ "LOOKUP_KEY_IN",
63
+ "LOOKUP_KEY_NOT_IN",
64
+ ]);
65
+ const LogLevelSchema = z.enum([
66
+ "TRACE",
67
+ "DEBUG",
68
+ "INFO",
69
+ "WARN",
70
+ "ERROR",
71
+ "FATAL",
72
+ ]);
73
+ const BoolValueSchema = z.object({ type: z.literal("bool"), value: z.boolean() });
74
+ const StringValueSchema = z.object({ type: z.literal("string"), value: z.string() });
75
+ const IntValueSchema = z.object({ type: z.literal("int"), value: z.union([z.number(), z.string()]) });
76
+ const DoubleValueSchema = z.object({ type: z.literal("double"), value: z.union([z.number(), z.string()]) });
77
+ const JsonValueSchema = z.object({ type: z.literal("json"), value: z.unknown() });
78
+ const StringListValueSchema = z.object({ type: z.literal("string_list"), value: z.array(z.string()) });
79
+ const DurationValueSchema = z.object({ type: z.literal("duration"), value: z.string() });
80
+ const LogLevelValueSchema = z.object({ type: z.literal("log_level"), value: LogLevelSchema });
81
+ const SchemaValueSchema = z.object({
82
+ type: z.literal("schema"),
83
+ value: z.object({ schemaType: z.string(), schema: z.string() }),
84
+ });
85
+ const ProvidedValueSchema = z.object({
86
+ type: z.literal("provided"),
87
+ value: z.object({ source: z.string(), lookup: z.string() }),
88
+ });
89
+ const ValueSchema = z.discriminatedUnion("type", [
90
+ BoolValueSchema,
91
+ StringValueSchema,
92
+ IntValueSchema,
93
+ DoubleValueSchema,
94
+ JsonValueSchema,
95
+ StringListValueSchema,
96
+ DurationValueSchema,
97
+ LogLevelValueSchema,
98
+ SchemaValueSchema,
99
+ ProvidedValueSchema,
100
+ ]);
101
+ const MAX_WEIGHT = 100_000;
102
+ const WeightedValueSchema = z.object({
103
+ value: ValueSchema,
104
+ weight: z.number().int().min(0).max(MAX_WEIGHT),
105
+ });
106
+ const WeightedValuesSchema = z.object({
107
+ type: z.literal("weighted_values"),
108
+ value: z.object({
109
+ weightedValues: z.array(WeightedValueSchema),
110
+ hashByPropertyName: z.string().default("user.key"),
111
+ splitEvenly: z.boolean().optional(),
112
+ }),
113
+ });
114
+ const RuleValueSchema = z.union([ValueSchema, WeightedValuesSchema]);
115
+ const CriterionSchema = z.object({
116
+ propertyName: z.string().optional(),
117
+ operator: OperatorSchema,
118
+ valueToMatch: ValueSchema.optional(),
119
+ });
120
+ const ConfigRuleSchema = z.object({
121
+ criteria: z.array(CriterionSchema),
122
+ value: RuleValueSchema,
123
+ });
124
+ /** Environment ID must be a slug: lowercase alphanumeric + dashes, not a UUID. */
125
+ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
126
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
127
+ const ConfigEnvironmentSchema = z.object({
128
+ id: z.string(),
129
+ rules: z.array(ConfigRuleSchema),
130
+ });
131
+ const VariantSchema = z.object({
132
+ id: z.string().optional(),
133
+ key: z.string().optional(),
134
+ value: ValueSchema,
135
+ description: z.string().optional(),
136
+ });
137
+ const StoredConfigSchema = z.object({
138
+ id: z.string().optional(),
139
+ projectId: z.string().optional(),
140
+ key: z.string(),
141
+ type: ConfigTypeSchema,
142
+ valueType: ValueTypeSchema,
143
+ name: z.string().optional(),
144
+ description: z.string().optional(),
145
+ sendToClientSdk: z.boolean().default(false),
146
+ schemaKey: z.string().optional(),
147
+ accessLevel: z.string().optional(),
148
+ protection: z.string().optional(),
149
+ default: z.object({
150
+ rules: z.array(ConfigRuleSchema),
151
+ }),
152
+ environments: z.array(ConfigEnvironmentSchema).default([]),
153
+ variants: z.array(VariantSchema).default([]),
154
+ }).passthrough(); // Allow extra fields (tags, schemaUsageMode, etc.)
155
+ const SchemaDocumentSchema = z.object({}).passthrough();
156
+ /** Maps config directory names to their expected config type. */
157
+ const DIR_TO_TYPE = {
158
+ "configs": "config",
159
+ "feature-flags": "feature_flag",
160
+ "segments": "segment",
161
+ "log-levels": "log_level",
162
+ };
163
+ const CONFIG_DIRS = new Set(Object.keys(DIR_TO_TYPE));
164
+ const SCHEMA_DIRS = new Set(["schemas", "schemas-protected"]);
165
+ const KNOWN_DIRS = new Set([...CONFIG_DIRS, ...SCHEMA_DIRS]);
166
+ // Operators that reference segments
167
+ const SEGMENT_OPERATORS = new Set(["IN_SEG", "NOT_IN_SEG"]);
168
+ // Operators that require a propertyName
169
+ const PROPERTY_OPERATORS = new Set([
170
+ "PROP_IS_ONE_OF", "PROP_IS_NOT_ONE_OF",
171
+ "PROP_STARTS_WITH_ONE_OF", "PROP_DOES_NOT_START_WITH_ONE_OF",
172
+ "PROP_ENDS_WITH_ONE_OF", "PROP_DOES_NOT_END_WITH_ONE_OF",
173
+ "PROP_CONTAINS_ONE_OF", "PROP_DOES_NOT_CONTAIN_ONE_OF",
174
+ "PROP_LESS_THAN", "PROP_LESS_THAN_OR_EQUAL",
175
+ "PROP_GREATER_THAN", "PROP_GREATER_THAN_OR_EQUAL",
176
+ "PROP_BEFORE", "PROP_AFTER",
177
+ "PROP_MATCHES", "PROP_DOES_NOT_MATCH",
178
+ ]);
179
+ // Operators that require a valueToMatch
180
+ const VALUE_REQUIRED_OPERATORS = new Set([
181
+ ...PROPERTY_OPERATORS,
182
+ "IN_SEG", "NOT_IN_SEG",
183
+ "IN_INT_RANGE",
184
+ "LOOKUP_KEY_IN", "LOOKUP_KEY_NOT_IN",
185
+ ]);
186
+ // ── Validation from filesystem ──────────────────────────────────────────
187
+ import * as fs from "node:fs";
188
+ import * as path from "node:path";
189
+ /**
190
+ * Validate an entire workspace directory on disk.
191
+ */
192
+ export function validateWorkspace(workspaceDir) {
193
+ const issues = [];
194
+ let filesChecked = 0;
195
+ // Collect all configs for cross-reference checks
196
+ const allConfigs = [];
197
+ const allSchemaFiles = [];
198
+ const segmentKeys = new Set();
199
+ const schemaKeys = new Set();
200
+ // Check for unexpected top-level entries
201
+ let entries;
202
+ try {
203
+ entries = fs.readdirSync(workspaceDir, { withFileTypes: true });
204
+ }
205
+ catch (err) {
206
+ return {
207
+ issues: [{
208
+ file: workspaceDir,
209
+ message: `Cannot read workspace directory: ${err.message}`,
210
+ severity: "error",
211
+ }],
212
+ filesChecked: 0,
213
+ valid: false,
214
+ };
215
+ }
216
+ for (const entry of entries) {
217
+ if (entry.name.startsWith("."))
218
+ continue; // skip .git, .qf, etc.
219
+ if (entry.isDirectory() && !KNOWN_DIRS.has(entry.name)) {
220
+ issues.push({
221
+ file: entry.name,
222
+ message: `Unexpected directory "${entry.name}"`,
223
+ severity: "warning",
224
+ suggestion: `Expected directories: ${[...KNOWN_DIRS].join(", ")}`,
225
+ });
226
+ }
227
+ }
228
+ // First pass: parse all files and collect keys
229
+ for (const dir of KNOWN_DIRS) {
230
+ const dirPath = path.join(workspaceDir, dir);
231
+ if (!fs.existsSync(dirPath))
232
+ continue;
233
+ const files = fs.readdirSync(dirPath).filter(f => f.endsWith(".json") && !f.startsWith("."));
234
+ for (const file of files) {
235
+ const filePath = path.join(dirPath, file);
236
+ const relPath = `${dir}/${file}`;
237
+ filesChecked++;
238
+ // Read and parse JSON
239
+ let raw;
240
+ try {
241
+ raw = fs.readFileSync(filePath, "utf-8");
242
+ }
243
+ catch (err) {
244
+ issues.push({ file: relPath, message: `Cannot read file: ${err.message}`, severity: "error" });
245
+ continue;
246
+ }
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(raw);
250
+ }
251
+ catch (err) {
252
+ issues.push({
253
+ file: relPath,
254
+ message: `Invalid JSON: ${err.message}`,
255
+ severity: "error",
256
+ });
257
+ continue;
258
+ }
259
+ if (SCHEMA_DIRS.has(dir)) {
260
+ const result = SchemaDocumentSchema.safeParse(parsed);
261
+ if (!result.success) {
262
+ for (const issue of result.error.issues) {
263
+ issues.push({
264
+ file: relPath,
265
+ message: `Schema: ${issue.path.join(".")} - ${issue.message}`,
266
+ severity: "error",
267
+ });
268
+ }
269
+ continue;
270
+ }
271
+ const schemaKey = file.replace(/\.json$/, "");
272
+ schemaKeys.add(schemaKey);
273
+ validateKey(schemaKey, relPath, issues);
274
+ allSchemaFiles.push({ key: schemaKey, file: relPath });
275
+ continue;
276
+ }
277
+ // Validate against StoredConfigSchema
278
+ const result = StoredConfigSchema.safeParse(parsed);
279
+ if (!result.success) {
280
+ for (const issue of result.error.issues) {
281
+ issues.push({
282
+ file: relPath,
283
+ message: `Schema: ${issue.path.join(".")} - ${issue.message}`,
284
+ severity: "error",
285
+ });
286
+ }
287
+ continue;
288
+ }
289
+ const config = result.data;
290
+ const expectedKey = file.replace(/\.json$/, "");
291
+ // Key matches filename
292
+ if (config.key !== expectedKey) {
293
+ issues.push({
294
+ file: relPath,
295
+ message: `Key "${config.key}" does not match filename "${expectedKey}"`,
296
+ severity: "error",
297
+ suggestion: `Rename file to "${config.key}.json" or set key to "${expectedKey}"`,
298
+ });
299
+ }
300
+ // Key constraints
301
+ validateKey(config.key, relPath, issues);
302
+ // Config type matches directory
303
+ const expectedType = DIR_TO_TYPE[dir];
304
+ if (expectedType && config.type !== expectedType) {
305
+ issues.push({
306
+ file: relPath,
307
+ message: `Type "${config.type}" in directory "${dir}" (expected "${expectedType}")`,
308
+ severity: "error",
309
+ suggestion: `Move to "${typeToDir(config.type)}/" or change type to "${expectedType}"`,
310
+ });
311
+ }
312
+ // Type-specific constraints
313
+ if (config.type === "segment") {
314
+ if (config.valueType !== "bool") {
315
+ issues.push({
316
+ file: relPath,
317
+ message: `Segment must have valueType "bool", got "${config.valueType}"`,
318
+ severity: "error",
319
+ });
320
+ }
321
+ if (config.sendToClientSdk) {
322
+ issues.push({
323
+ file: relPath,
324
+ message: `Segment must have sendToClientSdk=false`,
325
+ severity: "error",
326
+ });
327
+ }
328
+ segmentKeys.add(config.key);
329
+ }
330
+ if (config.type === "log_level" && config.valueType !== "log_level") {
331
+ issues.push({
332
+ file: relPath,
333
+ message: `Log level must have valueType "log_level", got "${config.valueType}"`,
334
+ severity: "error",
335
+ });
336
+ }
337
+ // Validate environment IDs are slugs, not UUIDs
338
+ validateEnvironmentIds(config.environments, relPath, issues);
339
+ // Validate rules
340
+ validateRules(config.default.rules, relPath, "default", config.valueType, issues);
341
+ for (const env of config.environments) {
342
+ validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues);
343
+ }
344
+ // Collect for cross-reference
345
+ allConfigs.push({ key: config.key, type: config.type, dir, file: relPath });
346
+ // Check schemaKey reference (collected, validated in second pass)
347
+ if (config.schemaKey) {
348
+ // Will be checked after all files are parsed
349
+ }
350
+ }
351
+ }
352
+ // Second pass: referential integrity
353
+ for (const dir of KNOWN_DIRS) {
354
+ const dirPath = path.join(workspaceDir, dir);
355
+ if (!fs.existsSync(dirPath))
356
+ continue;
357
+ const files = fs.readdirSync(dirPath).filter(f => f.endsWith(".json") && !f.startsWith("."));
358
+ for (const file of files) {
359
+ const filePath = path.join(dirPath, file);
360
+ const relPath = `${dir}/${file}`;
361
+ if (SCHEMA_DIRS.has(dir)) {
362
+ continue;
363
+ }
364
+ let raw;
365
+ try {
366
+ raw = fs.readFileSync(filePath, "utf-8");
367
+ }
368
+ catch {
369
+ continue;
370
+ }
371
+ let parsed;
372
+ try {
373
+ parsed = JSON.parse(raw);
374
+ }
375
+ catch {
376
+ continue;
377
+ }
378
+ const result = StoredConfigSchema.safeParse(parsed);
379
+ if (!result.success)
380
+ continue;
381
+ const config = result.data;
382
+ // Check segment references in criteria
383
+ const segRefs = collectSegmentReferences(config);
384
+ for (const ref of segRefs) {
385
+ if (!segmentKeys.has(ref)) {
386
+ issues.push({
387
+ file: relPath,
388
+ message: `References segment "${ref}" which does not exist`,
389
+ severity: "error",
390
+ suggestion: `Create segments/${ref}.json or remove the segment reference`,
391
+ });
392
+ }
393
+ }
394
+ // Check schemaKey references
395
+ if (config.schemaKey && !schemaKeys.has(config.schemaKey)) {
396
+ issues.push({
397
+ file: relPath,
398
+ message: `References schema "${config.schemaKey}" which does not exist`,
399
+ severity: "error",
400
+ suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
401
+ });
402
+ }
403
+ }
404
+ }
405
+ // Check for duplicate keys across directories
406
+ const keyToFiles = new Map();
407
+ for (const c of allConfigs) {
408
+ const existing = keyToFiles.get(c.key) || [];
409
+ existing.push(c.file);
410
+ keyToFiles.set(c.key, existing);
411
+ }
412
+ for (const [key, files] of keyToFiles) {
413
+ if (files.length > 1) {
414
+ issues.push({
415
+ file: files.join(", "),
416
+ message: `Duplicate key "${key}" found in multiple files`,
417
+ severity: "error",
418
+ suggestion: `Each key must be unique across the workspace`,
419
+ });
420
+ }
421
+ }
422
+ const schemaKeyToFiles = new Map();
423
+ for (const schemaFile of allSchemaFiles) {
424
+ const existing = schemaKeyToFiles.get(schemaFile.key) || [];
425
+ existing.push(schemaFile.file);
426
+ schemaKeyToFiles.set(schemaFile.key, existing);
427
+ }
428
+ for (const [key, files] of schemaKeyToFiles) {
429
+ if (files.length > 1) {
430
+ issues.push({
431
+ file: files.join(", "),
432
+ message: `Duplicate schema key "${key}" found in multiple files`,
433
+ severity: "error",
434
+ suggestion: `Each schema key must be unique across schema directories`,
435
+ });
436
+ }
437
+ }
438
+ const hasErrors = issues.some(i => i.severity === "error");
439
+ return { issues, filesChecked, valid: !hasErrors };
440
+ }
441
+ // ── Validate from in-memory file map (for git hook) ─────────────────────
442
+ /**
443
+ * Validate configs provided as a map of { "dir/file.json": jsonString }.
444
+ * Used by the pre-receive hook which reads files from git objects.
445
+ */
446
+ export function validateFileMap(files) {
447
+ const issues = [];
448
+ let filesChecked = 0;
449
+ const segmentKeys = new Set();
450
+ const schemaKeys = new Set();
451
+ const allSchemaFiles = [];
452
+ const allConfigs = [];
453
+ const parsedConfigs = [];
454
+ for (const [relPath, content] of files) {
455
+ const parts = relPath.split("/");
456
+ if (parts.length < 2)
457
+ continue;
458
+ const dir = parts[0];
459
+ const file = parts[parts.length - 1];
460
+ if (!KNOWN_DIRS.has(dir))
461
+ continue;
462
+ if (!file.endsWith(".json") || file.startsWith("."))
463
+ continue;
464
+ filesChecked++;
465
+ let parsed;
466
+ try {
467
+ parsed = JSON.parse(content);
468
+ }
469
+ catch (err) {
470
+ issues.push({ file: relPath, message: `Invalid JSON: ${err.message}`, severity: "error" });
471
+ continue;
472
+ }
473
+ if (SCHEMA_DIRS.has(dir)) {
474
+ const result = SchemaDocumentSchema.safeParse(parsed);
475
+ if (!result.success) {
476
+ for (const issue of result.error.issues) {
477
+ issues.push({
478
+ file: relPath,
479
+ message: `Schema: ${issue.path.join(".")} - ${issue.message}`,
480
+ severity: "error",
481
+ });
482
+ }
483
+ continue;
484
+ }
485
+ const schemaKey = file.replace(/\.json$/, "");
486
+ schemaKeys.add(schemaKey);
487
+ validateKey(schemaKey, relPath, issues);
488
+ allSchemaFiles.push({ key: schemaKey, file: relPath });
489
+ continue;
490
+ }
491
+ const result = StoredConfigSchema.safeParse(parsed);
492
+ if (!result.success) {
493
+ for (const issue of result.error.issues) {
494
+ issues.push({
495
+ file: relPath,
496
+ message: `Schema: ${issue.path.join(".")} - ${issue.message}`,
497
+ severity: "error",
498
+ });
499
+ }
500
+ continue;
501
+ }
502
+ const config = result.data;
503
+ const expectedKey = file.replace(/\.json$/, "");
504
+ if (config.key !== expectedKey) {
505
+ issues.push({
506
+ file: relPath,
507
+ message: `Key "${config.key}" does not match filename "${expectedKey}"`,
508
+ severity: "error",
509
+ suggestion: `Rename file to "${config.key}.json" or set key to "${expectedKey}"`,
510
+ });
511
+ }
512
+ validateKey(config.key, relPath, issues);
513
+ const expectedType = DIR_TO_TYPE[dir];
514
+ if (expectedType && config.type !== expectedType) {
515
+ issues.push({
516
+ file: relPath,
517
+ message: `Type "${config.type}" in directory "${dir}" (expected "${expectedType}")`,
518
+ severity: "error",
519
+ suggestion: `Move to "${typeToDir(config.type)}/" or change type to "${expectedType}"`,
520
+ });
521
+ }
522
+ if (config.type === "segment") {
523
+ if (config.valueType !== "bool") {
524
+ issues.push({ file: relPath, message: `Segment must have valueType "bool", got "${config.valueType}"`, severity: "error" });
525
+ }
526
+ if (config.sendToClientSdk) {
527
+ issues.push({ file: relPath, message: `Segment must have sendToClientSdk=false`, severity: "error" });
528
+ }
529
+ segmentKeys.add(config.key);
530
+ }
531
+ if (config.type === "log_level" && config.valueType !== "log_level") {
532
+ issues.push({ file: relPath, message: `Log level must have valueType "log_level", got "${config.valueType}"`, severity: "error" });
533
+ }
534
+ validateEnvironmentIds(config.environments, relPath, issues);
535
+ validateRules(config.default.rules, relPath, "default", config.valueType, issues);
536
+ for (const env of config.environments) {
537
+ validateRules(env.rules, relPath, `environments[${env.id}]`, config.valueType, issues);
538
+ }
539
+ allConfigs.push({ key: config.key, file: relPath });
540
+ parsedConfigs.push({ relPath, config, dir });
541
+ }
542
+ // Referential integrity
543
+ for (const { relPath, config } of parsedConfigs) {
544
+ const segRefs = collectSegmentReferences(config);
545
+ for (const ref of segRefs) {
546
+ if (!segmentKeys.has(ref)) {
547
+ issues.push({
548
+ file: relPath,
549
+ message: `References segment "${ref}" which does not exist`,
550
+ severity: "error",
551
+ suggestion: `Create segments/${ref}.json or remove the segment reference`,
552
+ });
553
+ }
554
+ }
555
+ if (config.schemaKey && !schemaKeys.has(config.schemaKey)) {
556
+ issues.push({
557
+ file: relPath,
558
+ message: `References schema "${config.schemaKey}" which does not exist`,
559
+ severity: "error",
560
+ suggestion: `Create schemas/${config.schemaKey}.json or schemas-protected/${config.schemaKey}.json, or remove schemaKey`,
561
+ });
562
+ }
563
+ }
564
+ // Duplicate keys
565
+ const keyToFiles = new Map();
566
+ for (const c of allConfigs) {
567
+ const existing = keyToFiles.get(c.key) || [];
568
+ existing.push(c.file);
569
+ keyToFiles.set(c.key, existing);
570
+ }
571
+ for (const [key, fileList] of keyToFiles) {
572
+ if (fileList.length > 1) {
573
+ issues.push({
574
+ file: fileList.join(", "),
575
+ message: `Duplicate key "${key}" found in multiple files`,
576
+ severity: "error",
577
+ });
578
+ }
579
+ }
580
+ const schemaKeyToFiles = new Map();
581
+ for (const schemaFile of allSchemaFiles) {
582
+ const existing = schemaKeyToFiles.get(schemaFile.key) || [];
583
+ existing.push(schemaFile.file);
584
+ schemaKeyToFiles.set(schemaFile.key, existing);
585
+ }
586
+ for (const [key, fileList] of schemaKeyToFiles) {
587
+ if (fileList.length > 1) {
588
+ issues.push({
589
+ file: fileList.join(", "),
590
+ message: `Duplicate schema key "${key}" found in multiple files`,
591
+ severity: "error",
592
+ });
593
+ }
594
+ }
595
+ const hasErrors = issues.some(i => i.severity === "error");
596
+ return { issues, filesChecked, valid: !hasErrors };
597
+ }
598
+ // ── Helpers ─────────────────────────────────────────────────────────────
599
+ function validateEnvironmentIds(environments, file, issues) {
600
+ for (const env of environments) {
601
+ if (UUID_RE.test(env.id)) {
602
+ issues.push({
603
+ file,
604
+ message: `Environment ID "${env.id}" is a UUID — must be a human-readable slug (e.g., "production", "my-ci-env")`,
605
+ severity: "error",
606
+ suggestion: `Replace the UUID with the environment's slugified name`,
607
+ });
608
+ }
609
+ else if (!SLUG_RE.test(env.id)) {
610
+ issues.push({
611
+ file,
612
+ message: `Environment ID "${env.id}" is not a valid slug (expected lowercase alphanumeric + dashes)`,
613
+ severity: "error",
614
+ suggestion: `Use a slugified name like "production" or "my-ci-env"`,
615
+ });
616
+ }
617
+ }
618
+ }
619
+ function validateKey(key, file, issues) {
620
+ if (key.length === 0) {
621
+ issues.push({ file, message: `Key is empty`, severity: "error" });
622
+ }
623
+ if (key.length > 512) {
624
+ issues.push({ file, message: `Key exceeds 512 characters (${key.length})`, severity: "error" });
625
+ }
626
+ if (key === "new") {
627
+ issues.push({ file, message: `Key cannot be "new" (reserved)`, severity: "error" });
628
+ }
629
+ if (/[/\\]/.test(key)) {
630
+ issues.push({ file, message: `Key contains slash or backslash`, severity: "error" });
631
+ }
632
+ }
633
+ function validateRules(rules, file, context, expectedValueType, issues) {
634
+ if (rules.length === 0) {
635
+ issues.push({ file, message: `${context}: no rules defined`, severity: "warning" });
636
+ }
637
+ for (let i = 0; i < rules.length; i++) {
638
+ const rule = rules[i];
639
+ const ruleCtx = `${context}.rules[${i}]`;
640
+ // Check value type consistency
641
+ // Skip weighted_values (contains its own values), schema, and provided (special types)
642
+ const SPECIAL_VALUE_TYPES = new Set(["weighted_values", "schema", "provided"]);
643
+ if (!SPECIAL_VALUE_TYPES.has(rule.value.type)) {
644
+ if (rule.value.type !== expectedValueType) {
645
+ issues.push({
646
+ file,
647
+ message: `${ruleCtx}: value type "${rule.value.type}" does not match config valueType "${expectedValueType}"`,
648
+ severity: "error",
649
+ });
650
+ }
651
+ }
652
+ else if (rule.value.type === "weighted_values") {
653
+ // Validate weighted values
654
+ const wv = rule.value.value;
655
+ if (wv.weightedValues.length === 0) {
656
+ issues.push({ file, message: `${ruleCtx}: weighted values list is empty`, severity: "error" });
657
+ }
658
+ for (let j = 0; j < wv.weightedValues.length; j++) {
659
+ if (wv.weightedValues[j].value.type !== expectedValueType) {
660
+ issues.push({
661
+ file,
662
+ message: `${ruleCtx}.weightedValues[${j}]: value type "${wv.weightedValues[j].value.type}" does not match config valueType "${expectedValueType}"`,
663
+ severity: "error",
664
+ });
665
+ }
666
+ }
667
+ }
668
+ // Check criteria
669
+ for (let j = 0; j < rule.criteria.length; j++) {
670
+ const criterion = rule.criteria[j];
671
+ const critCtx = `${ruleCtx}.criteria[${j}]`;
672
+ if (PROPERTY_OPERATORS.has(criterion.operator) && !criterion.propertyName) {
673
+ issues.push({
674
+ file,
675
+ message: `${critCtx}: operator "${criterion.operator}" requires propertyName`,
676
+ severity: "error",
677
+ });
678
+ }
679
+ if (VALUE_REQUIRED_OPERATORS.has(criterion.operator) && !criterion.valueToMatch) {
680
+ issues.push({
681
+ file,
682
+ message: `${critCtx}: operator "${criterion.operator}" requires valueToMatch`,
683
+ severity: "error",
684
+ });
685
+ }
686
+ }
687
+ }
688
+ }
689
+ function collectSegmentReferences(config) {
690
+ const refs = [];
691
+ function scanRules(rules) {
692
+ for (const rule of rules) {
693
+ for (const criterion of rule.criteria) {
694
+ if (SEGMENT_OPERATORS.has(criterion.operator) && criterion.valueToMatch) {
695
+ // The segment key is stored in the valueToMatch string value
696
+ const val = criterion.valueToMatch;
697
+ if (val.type === "string") {
698
+ refs.push(val.value);
699
+ }
700
+ else if (val.type === "string_list") {
701
+ refs.push(...val.value);
702
+ }
703
+ }
704
+ }
705
+ }
706
+ }
707
+ scanRules(config.default.rules);
708
+ for (const env of config.environments) {
709
+ scanRules(env.rules);
710
+ }
711
+ return refs;
712
+ }
713
+ function typeToDir(type) {
714
+ for (const [dir, t] of Object.entries(DIR_TO_TYPE)) {
715
+ if (t === type)
716
+ return dir;
717
+ }
718
+ return type;
719
+ }
720
+ // ── Output formatting ───────────────────────────────────────────────────
721
+ export function formatResult(result) {
722
+ const lines = [];
723
+ if (result.valid) {
724
+ lines.push(`OK: ${result.filesChecked} files checked, no errors found.`);
725
+ }
726
+ else {
727
+ const errors = result.issues.filter(i => i.severity === "error");
728
+ const warnings = result.issues.filter(i => i.severity === "warning");
729
+ lines.push(`FAILED: ${errors.length} error(s), ${warnings.length} warning(s) in ${result.filesChecked} files.\n`);
730
+ }
731
+ // Group issues by file
732
+ const byFile = new Map();
733
+ for (const issue of result.issues) {
734
+ const existing = byFile.get(issue.file) || [];
735
+ existing.push(issue);
736
+ byFile.set(issue.file, existing);
737
+ }
738
+ for (const [file, fileIssues] of byFile) {
739
+ lines.push(` ${file}:`);
740
+ for (const issue of fileIssues) {
741
+ const prefix = issue.severity === "error" ? "ERROR" : "WARN ";
742
+ lines.push(` ${prefix}: ${issue.message}`);
743
+ if (issue.suggestion) {
744
+ lines.push(` -> ${issue.suggestion}`);
745
+ }
746
+ }
747
+ lines.push("");
748
+ }
749
+ if (result.issues.length > 0) {
750
+ const warnings = result.issues.filter(i => i.severity === "warning");
751
+ if (warnings.length > 0 && result.valid) {
752
+ lines.push(`\n${warnings.length} warning(s) found.`);
753
+ }
754
+ }
755
+ return lines.join("\n");
756
+ }