@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.
- package/LICENSE +21 -0
- package/README.md +707 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +9 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +8 -0
- package/dist/api/get-environments.d.ts +10 -0
- package/dist/api/get-environments.js +11 -0
- package/dist/codegen/code-generators/base-generator.d.ts +11 -0
- package/dist/codegen/code-generators/base-generator.js +8 -0
- package/dist/codegen/code-generators/base-typescript-generator.d.ts +17 -0
- package/dist/codegen/code-generators/base-typescript-generator.js +35 -0
- package/dist/codegen/code-generators/node-typescript-generator.d.ts +8 -0
- package/dist/codegen/code-generators/node-typescript-generator.js +96 -0
- package/dist/codegen/code-generators/react-typescript-generator.d.ts +11 -0
- package/dist/codegen/code-generators/react-typescript-generator.js +116 -0
- package/dist/codegen/config-downloader.d.ts +7 -0
- package/dist/codegen/config-downloader.js +26 -0
- package/dist/codegen/json-schema-to-zod.d.ts +3 -0
- package/dist/codegen/json-schema-to-zod.js +170 -0
- package/dist/codegen/language-mappers/json-to-zod-mapper.d.ts +4 -0
- package/dist/codegen/language-mappers/json-to-zod-mapper.js +55 -0
- package/dist/codegen/language-mappers/zod-base-mapper.d.ts +35 -0
- package/dist/codegen/language-mappers/zod-base-mapper.js +111 -0
- package/dist/codegen/language-mappers/zod-to-string-mapper.d.ts +25 -0
- package/dist/codegen/language-mappers/zod-to-string-mapper.js +87 -0
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.d.ts +35 -0
- package/dist/codegen/language-mappers/zod-to-typescript-mapper.js +133 -0
- package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.d.ts +36 -0
- package/dist/codegen/language-mappers/zod-to-typescript-return-value-mapper.js +150 -0
- package/dist/codegen/mustache-extractor.d.ts +6 -0
- package/dist/codegen/mustache-extractor.js +83 -0
- package/dist/codegen/schema-evaluator.d.ts +37 -0
- package/dist/codegen/schema-evaluator.js +283 -0
- package/dist/codegen/schema-extractor.d.ts +24 -0
- package/dist/codegen/schema-extractor.js +265 -0
- package/dist/codegen/types.d.ts +44 -0
- package/dist/codegen/types.js +5 -0
- package/dist/codegen/zod-introspection.d.ts +85 -0
- package/dist/codegen/zod-introspection.js +205 -0
- package/dist/commands/create.d.ts +19 -0
- package/dist/commands/create.js +175 -0
- package/dist/commands/download.d.ts +13 -0
- package/dist/commands/download.js +63 -0
- package/dist/commands/generate-new-hex-key.d.ts +7 -0
- package/dist/commands/generate-new-hex-key.js +10 -0
- package/dist/commands/generate.d.ts +16 -0
- package/dist/commands/generate.js +220 -0
- package/dist/commands/get.d.ts +13 -0
- package/dist/commands/get.js +141 -0
- package/dist/commands/info.d.ts +21 -0
- package/dist/commands/info.js +464 -0
- package/dist/commands/interactive.d.ts +5 -0
- package/dist/commands/interactive.js +8 -0
- package/dist/commands/list.d.ts +13 -0
- package/dist/commands/list.js +61 -0
- package/dist/commands/login.d.ts +10 -0
- package/dist/commands/login.js +124 -0
- package/dist/commands/logout.d.ts +7 -0
- package/dist/commands/logout.js +12 -0
- package/dist/commands/mcp.d.ts +13 -0
- package/dist/commands/mcp.js +145 -0
- package/dist/commands/override.d.ts +17 -0
- package/dist/commands/override.js +170 -0
- package/dist/commands/profile.d.ts +7 -0
- package/dist/commands/profile.js +47 -0
- package/dist/commands/schema.d.ts +21 -0
- package/dist/commands/schema.js +168 -0
- package/dist/commands/serve.d.ts +13 -0
- package/dist/commands/serve.js +99 -0
- package/dist/commands/set-default.d.ts +20 -0
- package/dist/commands/set-default.js +273 -0
- package/dist/commands/verify.d.ts +12 -0
- package/dist/commands/verify.js +48 -0
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.js +52 -0
- package/dist/commands/workspace.d.ts +7 -0
- package/dist/commands/workspace.js +38 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/help.d.ts +4 -0
- package/dist/help.js +10 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +112 -0
- package/dist/interactive-prompt.d.ts +2 -0
- package/dist/interactive-prompt.js +162 -0
- package/dist/quonfig.d.ts +15 -0
- package/dist/quonfig.js +75 -0
- package/dist/result.d.ts +35 -0
- package/dist/result.js +9 -0
- package/dist/services/mcp.d.ts +15 -0
- package/dist/services/mcp.js +59 -0
- package/dist/ui/get-confirmation.d.ts +10 -0
- package/dist/ui/get-confirmation.js +4 -0
- package/dist/ui/get-environment.d.ts +11 -0
- package/dist/ui/get-environment.js +42 -0
- package/dist/ui/get-key.d.ts +27 -0
- package/dist/ui/get-key.js +15 -0
- package/dist/ui/get-string.d.ts +5 -0
- package/dist/ui/get-string.js +8 -0
- package/dist/ui/get-value.d.ts +18 -0
- package/dist/ui/get-value.js +46 -0
- package/dist/util/autocomplete.d.ts +6 -0
- package/dist/util/autocomplete.js +25 -0
- package/dist/util/coerce.d.ts +7 -0
- package/dist/util/coerce.js +62 -0
- package/dist/util/color.d.ts +3 -0
- package/dist/util/color.js +4 -0
- package/dist/util/config-value-dto.d.ts +16 -0
- package/dist/util/config-value-dto.js +82 -0
- package/dist/util/context.d.ts +18 -0
- package/dist/util/context.js +10 -0
- package/dist/util/domain-urls.d.ts +3 -0
- package/dist/util/domain-urls.js +18 -0
- package/dist/util/encryption.d.ts +20 -0
- package/dist/util/encryption.js +153 -0
- package/dist/util/file-manager.d.ts +12 -0
- package/dist/util/file-manager.js +16 -0
- package/dist/util/get-client.d.ts +7 -0
- package/dist/util/get-client.js +127 -0
- package/dist/util/is-interactive.d.ts +5 -0
- package/dist/util/is-interactive.js +7 -0
- package/dist/util/json-maybe.d.ts +3 -0
- package/dist/util/json-maybe.js +14 -0
- package/dist/util/name-arg.d.ts +4 -0
- package/dist/util/name-arg.js +3 -0
- package/dist/util/oauth-client.d.ts +30 -0
- package/dist/util/oauth-client.js +99 -0
- package/dist/util/secret-flags.d.ts +13 -0
- package/dist/util/secret-flags.js +13 -0
- package/dist/util/token-storage.d.ts +25 -0
- package/dist/util/token-storage.js +90 -0
- package/dist/validations/value.d.ts +4 -0
- package/dist/validations/value.js +11 -0
- package/dist/verify/standalone.d.ts +14 -0
- package/dist/verify/standalone.js +133 -0
- package/dist/verify/validate.d.ts +43 -0
- package/dist/verify/validate.js +756 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/oclif.manifest.json +1429 -0
- 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
|
+
}
|