@untangle-code/config 0.0.0-stage → 0.1.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 +661 -0
- package/NOTICE +90 -0
- package/README.md +262 -2
- package/THIRD_PARTY_LICENSES.md +29 -0
- package/dist/chunker-budget.d.ts +58 -0
- package/dist/chunker-budget.d.ts.map +1 -0
- package/dist/chunker-budget.js +11 -0
- package/dist/chunker-budget.js.map +1 -0
- package/dist/defaults.d.ts +50 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +179 -0
- package/dist/defaults.js.map +1 -0
- package/dist/discovery.d.ts +40 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +76 -0
- package/dist/discovery.js.map +1 -0
- package/dist/env.d.ts +37 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +100 -0
- package/dist/env.js.map +1 -0
- package/dist/hash.d.ts +37 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +52 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/load.d.ts +61 -0
- package/dist/load.d.ts.map +1 -0
- package/dist/load.js +264 -0
- package/dist/load.js.map +1 -0
- package/dist/merge.d.ts +32 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +65 -0
- package/dist/merge.js.map +1 -0
- package/dist/migrations.d.ts +26 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +40 -0
- package/dist/migrations.js.map +1 -0
- package/dist/parse.d.ts +7 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +14 -0
- package/dist/parse.js.map +1 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +37 -0
- package/dist/paths.js.map +1 -0
- package/dist/schema.d.ts +5 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +19 -0
- package/dist/schema.js.map +1 -0
- package/dist/secrets.d.ts +33 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +69 -0
- package/dist/secrets.js.map +1 -0
- package/dist/types.d.ts +323 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/user-config.d.ts +13 -0
- package/dist/user-config.d.ts.map +1 -0
- package/dist/user-config.js +34 -0
- package/dist/user-config.js.map +1 -0
- package/dist/validate.d.ts +36 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +337 -0
- package/dist/validate.js.map +1 -0
- package/package.json +65 -3
- package/schemas/config-v1.json +375 -0
- package/src/chunker-budget.ts +63 -0
- package/src/defaults.ts +207 -0
- package/src/discovery.ts +95 -0
- package/src/env.ts +141 -0
- package/src/hash.ts +71 -0
- package/src/index.ts +51 -0
- package/src/load.ts +346 -0
- package/src/merge.ts +69 -0
- package/src/migrations.ts +64 -0
- package/src/parse.ts +18 -0
- package/src/paths.ts +42 -0
- package/src/schema.ts +23 -0
- package/src/secrets.ts +78 -0
- package/src/types.ts +366 -0
- package/src/user-config.ts +48 -0
- package/src/validate.ts +430 -0
package/src/validate.ts
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration validation (spec.md §4.8):
|
|
3
|
+
*
|
|
4
|
+
* - unknown top-level fields SHOULD warn, never be silently dropped;
|
|
5
|
+
* - invalid enum values or unresolvable core values MUST abort startup;
|
|
6
|
+
* - dimension misconfiguration MUST be detectable before the first index upsert;
|
|
7
|
+
* - the validated effective profile MUST be stored with the index.
|
|
8
|
+
*
|
|
9
|
+
* This module never throws for warnings. It returns both lists; callers that
|
|
10
|
+
* want spec.md §4.8's "MUST abort" behavior call {@link assertConfigValid}.
|
|
11
|
+
*/
|
|
12
|
+
import { isErrorCode, UntangleError } from "@untangle-code/domain";
|
|
13
|
+
|
|
14
|
+
import { UNTANGLE_CONFIG_V1_KEYS } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export interface ConfigIssue {
|
|
17
|
+
/** An `ErrorCode` (domain) for errors; a short machine-readable slug for warnings. */
|
|
18
|
+
code: string;
|
|
19
|
+
message: string;
|
|
20
|
+
/** Dotted path into the config object, e.g. `"embeddings.mode"`. */
|
|
21
|
+
path: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ValidationResult {
|
|
25
|
+
errors: ConfigIssue[];
|
|
26
|
+
warnings: ConfigIssue[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** URI-backed and network locations are outside the embedded backend contract. */
|
|
34
|
+
function isLocalStoragePath(value: unknown): value is string {
|
|
35
|
+
if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) return false;
|
|
36
|
+
if (/^[\\/]{2}/u.test(value)) return false;
|
|
37
|
+
if (/^[A-Za-z]:[\\/]/u.test(value)) return true;
|
|
38
|
+
return !/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function checkEnum(
|
|
42
|
+
value: unknown,
|
|
43
|
+
allowed: readonly string[],
|
|
44
|
+
path: string,
|
|
45
|
+
errors: ConfigIssue[],
|
|
46
|
+
): void {
|
|
47
|
+
if (value === undefined) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
51
|
+
errors.push({
|
|
52
|
+
code: "CONFIG_INVALID",
|
|
53
|
+
message: `"${path}" must be one of ${allowed.map((v) => `"${v}"`).join(", ")}, got ${JSON.stringify(value)}`,
|
|
54
|
+
path,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function checkPositiveInteger(value: unknown, path: string, errors: ConfigIssue[]): void {
|
|
60
|
+
if (value === undefined) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
64
|
+
errors.push({
|
|
65
|
+
code: "CONFIG_INVALID",
|
|
66
|
+
message: `"${path}" must be a positive integer, got ${JSON.stringify(value)}`,
|
|
67
|
+
path,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkFiniteRange(
|
|
73
|
+
value: unknown,
|
|
74
|
+
minimum: number,
|
|
75
|
+
maximum: number,
|
|
76
|
+
path: string,
|
|
77
|
+
errors: ConfigIssue[],
|
|
78
|
+
): void {
|
|
79
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
|
|
80
|
+
errors.push({
|
|
81
|
+
code: "CONFIG_INVALID",
|
|
82
|
+
message: `"${path}" must be a finite number in [${String(minimum)}, ${String(maximum)}], got ${JSON.stringify(value)}`,
|
|
83
|
+
path,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateRelevancePolicies(search: Record<string, unknown>, errors: ConfigIssue[]): void {
|
|
89
|
+
const policies = search["relevancePolicies"];
|
|
90
|
+
if (policies === undefined) return;
|
|
91
|
+
if (!isRecord(policies)) {
|
|
92
|
+
errors.push({
|
|
93
|
+
code: "CONFIG_INVALID",
|
|
94
|
+
message: '"search.relevancePolicies" must be an object keyed by embedding profile id',
|
|
95
|
+
path: "search.relevancePolicies",
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const [profileId, value] of Object.entries(policies)) {
|
|
101
|
+
const basePath = `search.relevancePolicies.${profileId}`;
|
|
102
|
+
if (profileId.trim() === "") {
|
|
103
|
+
errors.push({
|
|
104
|
+
code: "CONFIG_INVALID",
|
|
105
|
+
message: '"search.relevancePolicies" keys must be non-empty embedding profile ids',
|
|
106
|
+
path: "search.relevancePolicies",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (!isRecord(value)) {
|
|
110
|
+
errors.push({
|
|
111
|
+
code: "CONFIG_INVALID",
|
|
112
|
+
message: `"${basePath}" must be a relevance policy object`,
|
|
113
|
+
path: basePath,
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const scopeField of ["workspaceId", "collection"] as const) {
|
|
118
|
+
if (typeof value[scopeField] !== "string" || value[scopeField].trim() === "") {
|
|
119
|
+
errors.push({
|
|
120
|
+
code: "CONFIG_INVALID",
|
|
121
|
+
message: `"${basePath}.${scopeField}" must be a non-empty string`,
|
|
122
|
+
path: `${basePath}.${scopeField}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const policy = value["policy"];
|
|
127
|
+
if (!isRecord(policy)) {
|
|
128
|
+
errors.push({
|
|
129
|
+
code: "CONFIG_INVALID",
|
|
130
|
+
message: `"${basePath}.policy" must be a relevance policy object`,
|
|
131
|
+
path: `${basePath}.policy`,
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const policyPath = `${basePath}.policy`;
|
|
136
|
+
if (typeof policy["calibrationId"] !== "string" || policy["calibrationId"].trim() === "") {
|
|
137
|
+
errors.push({
|
|
138
|
+
code: "CONFIG_INVALID",
|
|
139
|
+
message: `"${policyPath}.calibrationId" must be a non-empty string`,
|
|
140
|
+
path: `${policyPath}.calibrationId`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
checkFiniteRange(policy["minimumDenseScore"], -1, 1, `${policyPath}.minimumDenseScore`, errors);
|
|
144
|
+
checkFiniteRange(
|
|
145
|
+
policy["minimumDenseSpread"],
|
|
146
|
+
0,
|
|
147
|
+
2,
|
|
148
|
+
`${policyPath}.minimumDenseSpread`,
|
|
149
|
+
errors,
|
|
150
|
+
);
|
|
151
|
+
const referenceRank = policy["denseReferenceRank"];
|
|
152
|
+
if (
|
|
153
|
+
typeof referenceRank !== "number" ||
|
|
154
|
+
!Number.isInteger(referenceRank) ||
|
|
155
|
+
referenceRank < 2
|
|
156
|
+
) {
|
|
157
|
+
errors.push({
|
|
158
|
+
code: "CONFIG_INVALID",
|
|
159
|
+
message: `"${policyPath}.denseReferenceRank" must be an integer >= 2, got ${JSON.stringify(referenceRank)}`,
|
|
160
|
+
path: `${policyPath}.denseReferenceRank`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
checkFiniteRange(
|
|
164
|
+
policy["minimumCandidateIdfCoverage"],
|
|
165
|
+
0,
|
|
166
|
+
1,
|
|
167
|
+
`${policyPath}.minimumCandidateIdfCoverage`,
|
|
168
|
+
errors,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Validates a fully layer-merged (but not yet necessarily complete-with-defaults)
|
|
175
|
+
* configuration object. Returns warnings for unknown top-level fields and
|
|
176
|
+
* errors for invalid enums, unresolvable core values, and structural
|
|
177
|
+
* uniqueness violations (spec.md §4.3 repository ids; portfolio.md §14.4
|
|
178
|
+
* blueprint weight sum).
|
|
179
|
+
*/
|
|
180
|
+
export function validateEffectiveConfig(config: Record<string, unknown>): ValidationResult {
|
|
181
|
+
const errors: ConfigIssue[] = [];
|
|
182
|
+
const warnings: ConfigIssue[] = [];
|
|
183
|
+
|
|
184
|
+
// Unknown top-level fields (spec.md §4.8). `$schema` is an accepted,
|
|
185
|
+
// ignored editor/tooling hint (product.md §11's example config carries it).
|
|
186
|
+
for (const key of Object.keys(config)) {
|
|
187
|
+
if (key === "$schema") {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (!(UNTANGLE_CONFIG_V1_KEYS as readonly string[]).includes(key)) {
|
|
191
|
+
warnings.push({
|
|
192
|
+
code: "unknown_field",
|
|
193
|
+
message: `unknown top-level config field "${key}" is ignored`,
|
|
194
|
+
path: key,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (config["schemaVersion"] !== undefined && config["schemaVersion"] !== 1) {
|
|
200
|
+
errors.push({
|
|
201
|
+
code: "CONFIG_INVALID",
|
|
202
|
+
message: `unsupported "schemaVersion" ${JSON.stringify(config["schemaVersion"])}; only 1 is understood without migration`,
|
|
203
|
+
path: "schemaVersion",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const storage = config["storage"];
|
|
208
|
+
if (storage !== undefined) {
|
|
209
|
+
if (!isRecord(storage)) {
|
|
210
|
+
errors.push({
|
|
211
|
+
code: "CONFIG_INVALID",
|
|
212
|
+
message: '"storage" must be an object',
|
|
213
|
+
path: "storage",
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
checkEnum(storage["backend"], ["lancedb", "qdrant"], "storage.backend", errors);
|
|
217
|
+
if (storage["path"] !== undefined && !isLocalStoragePath(storage["path"])) {
|
|
218
|
+
errors.push({
|
|
219
|
+
code: "CONFIG_INVALID",
|
|
220
|
+
message:
|
|
221
|
+
'"storage.path" must be a non-empty local filesystem path, not a URI or network path',
|
|
222
|
+
path: "storage.path",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const qdrant = config["qdrant"];
|
|
229
|
+
if (isRecord(qdrant)) {
|
|
230
|
+
checkEnum(qdrant["mode"], ["managed", "external"], "qdrant.mode", errors);
|
|
231
|
+
if (
|
|
232
|
+
qdrant["mode"] === "external" &&
|
|
233
|
+
(typeof qdrant["url"] !== "string" || qdrant["url"].length === 0)
|
|
234
|
+
) {
|
|
235
|
+
errors.push({
|
|
236
|
+
code: "CONFIG_INVALID",
|
|
237
|
+
message: `"qdrant.url" is required when "qdrant.mode" is "external"`,
|
|
238
|
+
path: "qdrant.url",
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const embeddings = config["embeddings"];
|
|
244
|
+
if (isRecord(embeddings)) {
|
|
245
|
+
checkEnum(embeddings["provider"], ["ollama"], "embeddings.provider", errors);
|
|
246
|
+
checkEnum(embeddings["mode"], ["auto", "managed", "external"], "embeddings.mode", errors);
|
|
247
|
+
checkPositiveInteger(embeddings["dimensions"], "embeddings.dimensions", errors);
|
|
248
|
+
if (
|
|
249
|
+
embeddings["mode"] === "external" &&
|
|
250
|
+
(typeof embeddings["url"] !== "string" || embeddings["url"].length === 0)
|
|
251
|
+
) {
|
|
252
|
+
errors.push({
|
|
253
|
+
code: "CONFIG_INVALID",
|
|
254
|
+
message: `"embeddings.url" is required when "embeddings.mode" is "external"`,
|
|
255
|
+
path: "embeddings.url",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const search = config["search"];
|
|
261
|
+
if (isRecord(search)) {
|
|
262
|
+
validateRelevancePolicies(search, errors);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const qualityGate = config["qualityGate"];
|
|
266
|
+
if (isRecord(qualityGate)) {
|
|
267
|
+
checkEnum(qualityGate["mode"], ["off", "warn", "fail"], "qualityGate.mode", errors);
|
|
268
|
+
checkEnum(
|
|
269
|
+
qualityGate["minimumParseQuality"],
|
|
270
|
+
["failed", "partial", "complete"],
|
|
271
|
+
"qualityGate.minimumParseQuality",
|
|
272
|
+
errors,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const complexity = config["complexity"];
|
|
277
|
+
if (isRecord(complexity)) {
|
|
278
|
+
checkEnum(
|
|
279
|
+
complexity["cyclomaticVariant"],
|
|
280
|
+
["classic", "extended", "both"],
|
|
281
|
+
"complexity.cyclomaticVariant",
|
|
282
|
+
errors,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const telemetry = config["telemetry"];
|
|
287
|
+
if (isRecord(telemetry)) {
|
|
288
|
+
checkEnum(
|
|
289
|
+
telemetry["logLevel"],
|
|
290
|
+
["error", "warn", "info", "debug", "trace"],
|
|
291
|
+
"telemetry.logLevel",
|
|
292
|
+
errors,
|
|
293
|
+
);
|
|
294
|
+
checkEnum(
|
|
295
|
+
telemetry["metricsExport"],
|
|
296
|
+
["none", "prometheus"],
|
|
297
|
+
"telemetry.metricsExport",
|
|
298
|
+
errors,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const portfolio = config["portfolio"];
|
|
303
|
+
if (isRecord(portfolio)) {
|
|
304
|
+
const enrichment = portfolio["enrichment"];
|
|
305
|
+
if (isRecord(enrichment) && Array.isArray(enrichment["registries"])) {
|
|
306
|
+
const allowed = ["npm", "pypi", "crates", "maven", "nuget", "go", "composer"];
|
|
307
|
+
for (const [index, registry] of (enrichment["registries"] as unknown[]).entries()) {
|
|
308
|
+
checkEnum(registry, allowed, `portfolio.enrichment.registries[${index}]`, errors);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const weights = isRecord(portfolio["blueprint"])
|
|
312
|
+
? portfolio["blueprint"]["weights"]
|
|
313
|
+
: undefined;
|
|
314
|
+
if (isRecord(weights)) {
|
|
315
|
+
const adoption = typeof weights["adoption"] === "number" ? weights["adoption"] : 0;
|
|
316
|
+
const recency = typeof weights["recency"] === "number" ? weights["recency"] : 0;
|
|
317
|
+
const health = typeof weights["health"] === "number" ? weights["health"] : 0;
|
|
318
|
+
const versionConsistency =
|
|
319
|
+
typeof weights["versionConsistency"] === "number" ? weights["versionConsistency"] : 0;
|
|
320
|
+
const sum = adoption + recency + health + versionConsistency;
|
|
321
|
+
if (Math.abs(sum - 1.0) > 0.001) {
|
|
322
|
+
errors.push({
|
|
323
|
+
code: "CONFIG_INVALID",
|
|
324
|
+
message: `"portfolio.blueprint.weights" must sum to 1.0 (±0.001), got ${sum}`,
|
|
325
|
+
path: "portfolio.blueprint.weights",
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Repository id uniqueness (spec.md §4.3: "id MUSS innerhalb eines Workspaces eindeutig sein").
|
|
332
|
+
const repositories = config["repositories"];
|
|
333
|
+
if (Array.isArray(repositories)) {
|
|
334
|
+
const seen = new Set<string>();
|
|
335
|
+
for (const [index, repo] of repositories.entries()) {
|
|
336
|
+
const id = isRecord(repo) ? repo["id"] : undefined;
|
|
337
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
338
|
+
errors.push({
|
|
339
|
+
code: "CONFIG_INVALID",
|
|
340
|
+
message: `"repositories[${index}].id" is required`,
|
|
341
|
+
path: `repositories[${index}].id`,
|
|
342
|
+
});
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (seen.has(id)) {
|
|
346
|
+
errors.push({
|
|
347
|
+
code: "CONFIG_INVALID",
|
|
348
|
+
message: `duplicate repository id "${id}"`,
|
|
349
|
+
path: `repositories[${index}].id`,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
seen.add(id);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Context artifact name uniqueness (spec.md §19.2: unique per project).
|
|
357
|
+
const contextArtifacts = config["contextArtifacts"];
|
|
358
|
+
if (Array.isArray(contextArtifacts)) {
|
|
359
|
+
const seen = new Set<string>();
|
|
360
|
+
for (const [index, artifact] of contextArtifacts.entries()) {
|
|
361
|
+
const name = isRecord(artifact) ? artifact["name"] : undefined;
|
|
362
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
363
|
+
errors.push({
|
|
364
|
+
code: "CONFIG_INVALID",
|
|
365
|
+
message: `"contextArtifacts[${index}].name" is required`,
|
|
366
|
+
path: `contextArtifacts[${index}].name`,
|
|
367
|
+
});
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (seen.has(name)) {
|
|
371
|
+
errors.push({
|
|
372
|
+
code: "CONFIG_INVALID",
|
|
373
|
+
message: `duplicate context artifact name "${name}"`,
|
|
374
|
+
path: `contextArtifacts[${index}].name`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
seen.add(name);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { errors, warnings };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Throws `UntangleError("CONFIG_INVALID", …)` when `errors` is non-empty
|
|
386
|
+
* (spec.md §4.8: invalid enums / unresolvable core values MUST abort
|
|
387
|
+
* startup). Callers decide when to call this — `loadConfig` itself never
|
|
388
|
+
* throws for either warnings or errors, so a caller can inspect both lists
|
|
389
|
+
* first (e.g. to print them) before deciding to abort.
|
|
390
|
+
*/
|
|
391
|
+
export function assertConfigValid(errors: readonly ConfigIssue[]): void {
|
|
392
|
+
if (errors.length === 0) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const code = isErrorCode(errors[0]?.code) ? errors[0].code : "CONFIG_INVALID";
|
|
396
|
+
throw new UntangleError(
|
|
397
|
+
code,
|
|
398
|
+
`configuration is invalid: ${errors.map((e) => e.message).join("; ")}`,
|
|
399
|
+
{
|
|
400
|
+
operation: "config.validate",
|
|
401
|
+
context: { issues: errors },
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Pre-upsert dimension check (spec.md §4.8: "Falsch konfigurierte Dimensionen
|
|
408
|
+
* MÜSSEN vor dem ersten Index-Upsert erkannt werden."). Callers invoke this
|
|
409
|
+
* with the configured `embeddings.dimensions` and the dimension already
|
|
410
|
+
* committed to an existing Qdrant collection (from `MetadataStore`/
|
|
411
|
+
* `VectorStore` introspection) before ANY upsert into that collection.
|
|
412
|
+
*/
|
|
413
|
+
export function assertEmbeddingDimensionsMatch(
|
|
414
|
+
configuredDimensions: number,
|
|
415
|
+
existingCollectionDimensions: number | undefined,
|
|
416
|
+
): void {
|
|
417
|
+
if (existingCollectionDimensions === undefined) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (configuredDimensions !== existingCollectionDimensions) {
|
|
421
|
+
throw new UntangleError(
|
|
422
|
+
"EMBEDDING_DIMENSION_MISMATCH",
|
|
423
|
+
`configured embedding dimensions (${configuredDimensions}) do not match the existing collection's dimensions (${existingCollectionDimensions})`,
|
|
424
|
+
{
|
|
425
|
+
operation: "config.validate",
|
|
426
|
+
context: { configuredDimensions, existingCollectionDimensions },
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|