@telorun/analyzer 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/analysis-registry.d.ts +12 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +15 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +131 -85
- package/dist/builtins.d.ts.map +1 -1
- package/dist/builtins.js +25 -0
- package/dist/cel-environment.d.ts +1 -1
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +40 -2
- package/dist/definition-registry.d.ts +12 -1
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +20 -1
- package/dist/dependency-graph.d.ts.map +1 -1
- package/dist/dependency-graph.js +41 -62
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/kernel-globals.d.ts +1 -1
- package/dist/kernel-globals.d.ts.map +1 -1
- package/dist/kernel-globals.js +19 -1
- package/dist/manifest-visitor.d.ts +109 -0
- package/dist/manifest-visitor.d.ts.map +1 -0
- package/dist/manifest-visitor.js +110 -0
- package/dist/reference-field-map.d.ts +1 -0
- package/dist/reference-field-map.d.ts.map +1 -1
- package/dist/reference-field-map.js +1 -1
- package/dist/schema-compat.d.ts +14 -0
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +38 -2
- package/dist/validate-cel-context.d.ts +14 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +38 -0
- package/dist/validate-nested-inline.d.ts +30 -0
- package/dist/validate-nested-inline.d.ts.map +1 -0
- package/dist/validate-nested-inline.js +129 -0
- package/dist/validate-references.d.ts.map +1 -1
- package/dist/validate-references.js +117 -160
- package/dist/validate-unused-declarations.d.ts +25 -0
- package/dist/validate-unused-declarations.d.ts.map +1 -0
- package/dist/validate-unused-declarations.js +91 -0
- package/package.json +2 -2
- package/src/analysis-registry.ts +20 -0
- package/src/analyzer.ts +217 -158
- package/src/builtins.ts +25 -0
- package/src/cel-environment.ts +42 -1
- package/src/definition-registry.ts +20 -1
- package/src/dependency-graph.ts +37 -52
- package/src/index.ts +11 -0
- package/src/kernel-globals.ts +22 -1
- package/src/manifest-visitor.ts +251 -0
- package/src/reference-field-map.ts +1 -1
- package/src/schema-compat.ts +38 -2
- package/src/validate-cel-context.ts +50 -0
- package/src/validate-nested-inline.ts +158 -0
- package/src/validate-references.ts +168 -211
- package/src/validate-unused-declarations.ts +95 -0
- package/dist/adapters/http-adapter.d.ts +0 -10
- package/dist/adapters/http-adapter.d.ts.map +0 -1
- package/dist/adapters/http-adapter.js +0 -18
- package/dist/adapters/node-adapter.d.ts +0 -17
- package/dist/adapters/node-adapter.d.ts.map +0 -1
- package/dist/adapters/node-adapter.js +0 -71
- package/dist/adapters/registry-adapter.d.ts +0 -15
- package/dist/adapters/registry-adapter.d.ts.map +0 -1
- package/dist/adapters/registry-adapter.js +0 -53
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
import { collectRefs, isInlineResource } from "./reference-field-map.js";
|
|
3
|
+
import {
|
|
4
|
+
collectProperties,
|
|
5
|
+
resolveRef,
|
|
6
|
+
substituteCelFields,
|
|
7
|
+
validateAgainstSchema,
|
|
8
|
+
} from "./schema-compat.js";
|
|
9
|
+
import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const SOURCE = "telo-analyzer";
|
|
12
|
+
|
|
13
|
+
/** Minimal view of a definition needed to validate an inline resource's config. */
|
|
14
|
+
export interface InlineDefinitionLookup {
|
|
15
|
+
(kind: string): { schema?: Record<string, any> } | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validates inline resources nested inside a resource body against their kind's
|
|
20
|
+
* config schema. The per-resource walk in `analyze()` validates a resource's
|
|
21
|
+
* own top-level config; inline resources at `x-telo-ref` slots reachable only
|
|
22
|
+
* through a local `$ref` (notably `Run.Sequence`'s `steps[].invoke`, hidden
|
|
23
|
+
* behind `#/$defs/step`) never reach the reference field map, so they would
|
|
24
|
+
* otherwise escape schema validation — e.g. `invoke: { kind: Console.ReadLine,
|
|
25
|
+
* prompt: "…" }`, where `prompt` belongs in the step's `inputs`, not the config.
|
|
26
|
+
*
|
|
27
|
+
* Walks the manifest data together with its definition schema, resolving local
|
|
28
|
+
* `$ref`s (so step trees of arbitrary depth are covered). At each `x-telo-ref`
|
|
29
|
+
* slot holding an inline resource, the inline's config is validated against its
|
|
30
|
+
* own kind's schema, then recursed into so inline resources nested inside
|
|
31
|
+
* inline resources are covered.
|
|
32
|
+
*
|
|
33
|
+
* Non-mutating: reads `manifest` and emits diagnostics anchored to its identity
|
|
34
|
+
* and a concrete dotted path matching the position-index key format;
|
|
35
|
+
* `rewriteSyntheticOrigins` reroutes those on inline-extracted (synthetic)
|
|
36
|
+
* manifests back to the root doc.
|
|
37
|
+
*/
|
|
38
|
+
export function validateNestedInlineResources(
|
|
39
|
+
manifest: ResourceManifest,
|
|
40
|
+
rootSchema: Record<string, any>,
|
|
41
|
+
lookupDefinition: InlineDefinitionLookup,
|
|
42
|
+
): AnalysisDiagnostic[] {
|
|
43
|
+
const diagnostics: AnalysisDiagnostic[] = [];
|
|
44
|
+
const resource = { kind: manifest.kind, name: manifest.metadata?.name as string };
|
|
45
|
+
const filePath = (manifest.metadata as { source?: string } | undefined)?.source;
|
|
46
|
+
|
|
47
|
+
function validateInline(inline: Record<string, any>, path: string): void {
|
|
48
|
+
const kind = inline.kind as string;
|
|
49
|
+
const def = lookupDefinition(kind);
|
|
50
|
+
// Unknown kind: these `$ref`-hidden slots are invisible to the field-map
|
|
51
|
+
// driven reference checks too, so nothing else would flag it — report here.
|
|
52
|
+
if (!def) {
|
|
53
|
+
diagnostics.push({
|
|
54
|
+
severity: DiagnosticSeverity.Error,
|
|
55
|
+
code: "UNDEFINED_KIND",
|
|
56
|
+
source: SOURCE,
|
|
57
|
+
message: `${resource.kind}/${resource.name}: inline ${kind} at '${path}': No Telo.Definition found for kind '${kind}'.`,
|
|
58
|
+
data: { resource, filePath, path: `${path}.kind` },
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Kind exists but declares no config schema (e.g. a pure Telo.Type): no
|
|
63
|
+
// config to validate and no schema-declared slots to nest resources in.
|
|
64
|
+
if (!def.schema) return;
|
|
65
|
+
const schema = def.schema;
|
|
66
|
+
// `kind` / `metadata` are implicit on every resource; inject them so a
|
|
67
|
+
// strict `additionalProperties: false` config schema doesn't reject them.
|
|
68
|
+
const effectiveSchema =
|
|
69
|
+
schema.additionalProperties === false
|
|
70
|
+
? {
|
|
71
|
+
...schema,
|
|
72
|
+
properties: {
|
|
73
|
+
kind: { type: "string" },
|
|
74
|
+
metadata: { type: "object" },
|
|
75
|
+
...schema.properties,
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
: schema;
|
|
79
|
+
// Inline resources omit `metadata` — it is synthesized when the kernel
|
|
80
|
+
// registers them (and by `normalizeInlineResources` for extracted slots,
|
|
81
|
+
// which assigns a derived `metadata.name`). Config schemas conventionally
|
|
82
|
+
// declare `required: ["metadata", …]` with `metadata.name` required, so add
|
|
83
|
+
// a placeholder before validating to mirror the post-registration shape.
|
|
84
|
+
const existingMeta =
|
|
85
|
+
inline.metadata && typeof inline.metadata === "object"
|
|
86
|
+
? (inline.metadata as Record<string, unknown>)
|
|
87
|
+
: {};
|
|
88
|
+
const data = { ...inline, metadata: { name: "__inline__", ...existingMeta } };
|
|
89
|
+
const substituted = substituteCelFields(data, effectiveSchema, effectiveSchema);
|
|
90
|
+
for (const issue of validateAgainstSchema(substituted, effectiveSchema)) {
|
|
91
|
+
diagnostics.push({
|
|
92
|
+
severity: DiagnosticSeverity.Error,
|
|
93
|
+
code: "SCHEMA_VIOLATION",
|
|
94
|
+
source: SOURCE,
|
|
95
|
+
message: `${resource.kind}/${resource.name}: inline ${kind} at '${path}': ${issue.message}`,
|
|
96
|
+
data: { resource, filePath, path: issue.path ? `${path}.${issue.path}` : path },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Recurse into the inline body against its own schema so deeper inline
|
|
100
|
+
// resources (e.g. an inline Run.Sequence's own steps) are validated too.
|
|
101
|
+
walk(inline, schema, schema, path);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function walk(
|
|
105
|
+
data: unknown,
|
|
106
|
+
schema: Record<string, any> | undefined,
|
|
107
|
+
schemaRoot: Record<string, any>,
|
|
108
|
+
path: string,
|
|
109
|
+
): void {
|
|
110
|
+
if (!schema || typeof schema !== "object") return;
|
|
111
|
+
const resolved = resolveRef(schema, schemaRoot);
|
|
112
|
+
|
|
113
|
+
// Reference slot: the value is either a named reference (`{kind, name}`,
|
|
114
|
+
// validated as its own manifest) or an inline resource to validate here.
|
|
115
|
+
if (collectRefs(resolved).length > 0) {
|
|
116
|
+
if (
|
|
117
|
+
data &&
|
|
118
|
+
typeof data === "object" &&
|
|
119
|
+
!Array.isArray(data) &&
|
|
120
|
+
isInlineResource(data as Record<string, unknown>)
|
|
121
|
+
) {
|
|
122
|
+
validateInline(data as Record<string, any>, path);
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (Array.isArray(data)) {
|
|
128
|
+
const itemSchema = resolved.items as Record<string, any> | undefined;
|
|
129
|
+
if (!itemSchema) return;
|
|
130
|
+
for (let i = 0; i < data.length; i++) {
|
|
131
|
+
walk(data[i], itemSchema, schemaRoot, `${path}[${i}]`);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (data && typeof data === "object") {
|
|
137
|
+
const props = collectProperties(resolved);
|
|
138
|
+
const additional =
|
|
139
|
+
resolved.additionalProperties &&
|
|
140
|
+
typeof resolved.additionalProperties === "object" &&
|
|
141
|
+
!Array.isArray(resolved.additionalProperties)
|
|
142
|
+
? (resolved.additionalProperties as Record<string, any>)
|
|
143
|
+
: undefined;
|
|
144
|
+
// Descend only where the schema declares structure. Freeform fields
|
|
145
|
+
// (`additionalProperties: true`, e.g. step `inputs`) carry caller data
|
|
146
|
+
// that may coincidentally look like `{kind: …}`; not descending there
|
|
147
|
+
// keeps the inline-resource detection anchored to real ref slots.
|
|
148
|
+
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
|
149
|
+
const propSchema = props[key] ?? additional;
|
|
150
|
+
if (!propSchema) continue;
|
|
151
|
+
walk(value, propSchema as Record<string, any>, schemaRoot, path ? `${path}.${key}` : key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
walk(manifest, rootSchema, rootSchema, "");
|
|
157
|
+
return diagnostics;
|
|
158
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
2
|
import { isRefSentinel } from "@telorun/templating";
|
|
3
|
-
import {
|
|
3
|
+
import { visitManifest } from "./manifest-visitor.js";
|
|
4
|
+
import { isInlineResource, resolveFieldEntries, resolveFieldValues, type RefFieldEntry } from "./reference-field-map.js";
|
|
4
5
|
import { navigateJsonPointer } from "./schema-compat.js";
|
|
5
6
|
import { REF_VALIDATION_SKIP_KINDS as SYSTEM_KINDS } from "./system-kinds.js";
|
|
6
7
|
import { DiagnosticSeverity, type AnalysisDiagnostic, type AnalysisContext } from "./types.js";
|
|
@@ -155,66 +156,20 @@ export function validateReferences(
|
|
|
155
156
|
const byName = new Map<string, ResourceManifest>();
|
|
156
157
|
for (const [name, list] of byNameAll) byName.set(name, list[0]);
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
:
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// Collect scope visibility prefixes (JSON Pointer → dot prefix) and their manifests.
|
|
174
|
-
// scope field path → flat array of ResourceManifest declared in that scope.
|
|
175
|
-
const scopeManifestsByPointer = new Map<string, ResourceManifest[]>();
|
|
176
|
-
for (const [fieldPath, entry] of fieldMap) {
|
|
177
|
-
if (!isScopeEntry(entry)) continue;
|
|
178
|
-
const raw = resolveFieldValues(r, fieldPath)
|
|
179
|
-
.flatMap((v) => (Array.isArray(v) ? v : [v]))
|
|
180
|
-
.filter((v): v is ResourceManifest => !!v && typeof v === "object");
|
|
181
|
-
const pointers = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
|
|
182
|
-
for (const pointer of pointers) {
|
|
183
|
-
scopeManifestsByPointer.set(pointer, raw);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const scopePrefixes = Array.from(scopeManifestsByPointer.keys()).map((p) =>
|
|
188
|
-
p.replace(/^\//, "").replace(/\//g, "."),
|
|
189
|
-
);
|
|
190
|
-
|
|
191
|
-
for (const [fieldPath, entry] of fieldMap) {
|
|
192
|
-
if (!isRefEntry(entry)) continue;
|
|
193
|
-
|
|
194
|
-
const inScope = scopePrefixes.some(
|
|
195
|
-
(prefix) =>
|
|
196
|
-
fieldPath === prefix ||
|
|
197
|
-
fieldPath.startsWith(prefix + ".") ||
|
|
198
|
-
fieldPath.startsWith(prefix + "["),
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
// Scope manifests visible to this ref path.
|
|
202
|
-
const visibleScopeManifests: ResourceManifest[] = [];
|
|
203
|
-
if (inScope) {
|
|
204
|
-
for (const [pointer, manifests] of scopeManifestsByPointer) {
|
|
205
|
-
const prefix = pointer.replace(/^\//, "").replace(/\//g, ".");
|
|
206
|
-
if (
|
|
207
|
-
fieldPath === prefix ||
|
|
208
|
-
fieldPath.startsWith(prefix + ".") ||
|
|
209
|
-
fieldPath.startsWith(prefix + "[")
|
|
210
|
-
) {
|
|
211
|
-
visibleScopeManifests.push(...manifests);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
for (const { value: val, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
|
|
217
|
-
if (!val) continue;
|
|
159
|
+
// Phase 3 — per-ref validation. The walker supplies each ref site already
|
|
160
|
+
// resolved against the schema-from-expanded field map, with its source
|
|
161
|
+
// enclosure (`inScope`) and the scope manifests visible to it — so this
|
|
162
|
+
// handler only validates, it does not re-walk.
|
|
163
|
+
visitManifest(
|
|
164
|
+
resources,
|
|
165
|
+
registry,
|
|
166
|
+
{
|
|
167
|
+
onRef: (e) => {
|
|
168
|
+
const r = e.source;
|
|
169
|
+
const resourceLabel = `${r.kind}/${r.metadata!.name as string}`;
|
|
170
|
+
const resourceData = { kind: r.kind, name: r.metadata!.name as string };
|
|
171
|
+
const filePath = (r.metadata as { source?: string } | undefined)?.source;
|
|
172
|
+
const { value: val, concretePath, entry, visibleScopeManifests } = e;
|
|
218
173
|
|
|
219
174
|
// `!ref <name>` sentinel — bare resource name marked at parse time as a
|
|
220
175
|
// reference. Look it up against the slot's x-telo-ref constraint exactly
|
|
@@ -233,7 +188,7 @@ export function validateReferences(
|
|
|
233
188
|
message: `${resourceLabel}: reference at '${concretePath}' → resource '${refName}' not found`,
|
|
234
189
|
data: { resource: resourceData, filePath, path: concretePath },
|
|
235
190
|
});
|
|
236
|
-
|
|
191
|
+
return;
|
|
237
192
|
}
|
|
238
193
|
const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
|
|
239
194
|
if (kindErrors.length > 0) {
|
|
@@ -245,7 +200,7 @@ export function validateReferences(
|
|
|
245
200
|
data: { resource: resourceData, filePath, path: concretePath },
|
|
246
201
|
});
|
|
247
202
|
}
|
|
248
|
-
|
|
203
|
+
return;
|
|
249
204
|
}
|
|
250
205
|
|
|
251
206
|
// Name-only reference (plain string) — look up by name to validate.
|
|
@@ -263,7 +218,7 @@ export function validateReferences(
|
|
|
263
218
|
// Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
|
|
264
219
|
// kinds — those must be validated.
|
|
265
220
|
if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
|
|
266
|
-
|
|
221
|
+
return;
|
|
267
222
|
}
|
|
268
223
|
diagnostics.push({
|
|
269
224
|
severity: DiagnosticSeverity.Error,
|
|
@@ -272,7 +227,7 @@ export function validateReferences(
|
|
|
272
227
|
message: `${resourceLabel}: reference at '${concretePath}' → resource '${val}' not found`,
|
|
273
228
|
data: { resource: resourceData, filePath, path: concretePath },
|
|
274
229
|
});
|
|
275
|
-
|
|
230
|
+
return;
|
|
276
231
|
}
|
|
277
232
|
const kindErrors = checkKind(target.kind as string, entry, registry, aliases);
|
|
278
233
|
if (kindErrors.length > 0) {
|
|
@@ -284,14 +239,14 @@ export function validateReferences(
|
|
|
284
239
|
data: { resource: resourceData, filePath, path: concretePath },
|
|
285
240
|
});
|
|
286
241
|
}
|
|
287
|
-
|
|
242
|
+
return;
|
|
288
243
|
}
|
|
289
244
|
|
|
290
|
-
if (typeof val !== "object")
|
|
245
|
+
if (typeof val !== "object") return;
|
|
291
246
|
const refVal = val as Record<string, unknown>;
|
|
292
247
|
|
|
293
248
|
// Skip inline resources — Phase 2 normalization hasn't run yet.
|
|
294
|
-
if (isInlineResource(refVal))
|
|
249
|
+
if (isInlineResource(refVal)) return;
|
|
295
250
|
|
|
296
251
|
// 1. Structural check
|
|
297
252
|
if (typeof refVal.kind !== "string" || typeof refVal.name !== "string") {
|
|
@@ -302,7 +257,7 @@ export function validateReferences(
|
|
|
302
257
|
message: `${resourceLabel}: reference at '${concretePath}' must have string 'kind' and 'name' fields`,
|
|
303
258
|
data: { resource: resourceData, filePath, path: concretePath },
|
|
304
259
|
});
|
|
305
|
-
|
|
260
|
+
return;
|
|
306
261
|
}
|
|
307
262
|
|
|
308
263
|
// 2. Kind check
|
|
@@ -330,177 +285,179 @@ export function validateReferences(
|
|
|
330
285
|
data: { resource: resourceData, filePath, path: concretePath },
|
|
331
286
|
});
|
|
332
287
|
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{ aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: true },
|
|
291
|
+
);
|
|
336
292
|
|
|
337
293
|
// Phase 3b — x-telo-schema-from validation.
|
|
338
294
|
// For each field with a schemaFrom path expression, resolve the anchor ref to get the
|
|
339
295
|
// concrete kind, navigate the JSON Pointer into that kind's definition schema, and
|
|
340
|
-
// validate the field value against the resulting sub-schema.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (slashIdx === -1) {
|
|
359
|
-
diagnostics.push({
|
|
360
|
-
severity: DiagnosticSeverity.Error,
|
|
361
|
-
code: "INVALID_SCHEMA_FROM",
|
|
362
|
-
source: SOURCE,
|
|
363
|
-
message: `${resourceLabel}: x-telo-schema-from "${schemaFrom}" must contain at least one "/" to separate anchor from JSON Pointer`,
|
|
364
|
-
data: { resource: resourceData, filePath, path: fieldPath },
|
|
365
|
-
});
|
|
366
|
-
continue;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const anchorName = expr.slice(0, slashIdx);
|
|
370
|
-
const jsonPointer = "/" + expr.slice(slashIdx + 1);
|
|
371
|
-
|
|
372
|
-
// Aliased absolute kind path — first segment carries a dot, e.g.
|
|
373
|
-
// "HttpDispatch.Outcomes/$defs/Returns". Resolves the alias through the
|
|
374
|
-
// *kind owner's* scope (not the consumer's), navigates the JSON Pointer
|
|
375
|
-
// into the resolved definition's schema, and validates each field value.
|
|
376
|
-
//
|
|
377
|
-
// Relative anchors are property names that cannot contain a dot
|
|
378
|
-
// (CEL-style identifiers), so a dot in anchorName is unambiguous.
|
|
379
|
-
if (!isAbsolute && anchorName.includes(".")) {
|
|
380
|
-
const resolvedResourceKind = aliases.resolveKind(r.kind) ?? r.kind;
|
|
381
|
-
const resourceDef =
|
|
382
|
-
registry.resolve(r.kind) ?? registry.resolve(resolvedResourceKind);
|
|
383
|
-
const owningModule = (resourceDef?.metadata as { module?: string } | undefined)?.module;
|
|
384
|
-
const ownerScope =
|
|
385
|
-
(owningModule ? aliasesByModule?.get(owningModule) : undefined) ?? aliases;
|
|
386
|
-
|
|
387
|
-
const targetKind = ownerScope.resolveKind(anchorName);
|
|
388
|
-
if (!targetKind) {
|
|
296
|
+
// validate the field value against the resulting sub-schema. Driven off the base map
|
|
297
|
+
// (un-expanded) so each schema-from slot is seen as its own site.
|
|
298
|
+
visitManifest(
|
|
299
|
+
resources,
|
|
300
|
+
registry,
|
|
301
|
+
{
|
|
302
|
+
onSchemaFrom: (e) => {
|
|
303
|
+
const r = e.source;
|
|
304
|
+
const fieldPath = e.fieldPath;
|
|
305
|
+
const resourceLabel = `${r.kind}/${r.metadata!.name as string}`;
|
|
306
|
+
const resourceData = { kind: r.kind, name: r.metadata!.name as string };
|
|
307
|
+
const filePath = (r.metadata as { source?: string } | undefined)?.source;
|
|
308
|
+
|
|
309
|
+
const { schemaFrom } = e.entry;
|
|
310
|
+
const isAbsolute = schemaFrom.startsWith("/");
|
|
311
|
+
const expr = isAbsolute ? schemaFrom.slice(1) : schemaFrom;
|
|
312
|
+
const slashIdx = expr.indexOf("/");
|
|
313
|
+
if (slashIdx === -1) {
|
|
389
314
|
diagnostics.push({
|
|
390
315
|
severity: DiagnosticSeverity.Error,
|
|
391
|
-
code: "
|
|
316
|
+
code: "INVALID_SCHEMA_FROM",
|
|
392
317
|
source: SOURCE,
|
|
393
|
-
message: `${resourceLabel}: x-telo-schema-from
|
|
318
|
+
message: `${resourceLabel}: x-telo-schema-from "${schemaFrom}" must contain at least one "/" to separate anchor from JSON Pointer`,
|
|
394
319
|
data: { resource: resourceData, filePath, path: fieldPath },
|
|
395
320
|
});
|
|
396
|
-
|
|
321
|
+
return;
|
|
397
322
|
}
|
|
398
323
|
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
324
|
+
const anchorName = expr.slice(0, slashIdx);
|
|
325
|
+
const jsonPointer = "/" + expr.slice(slashIdx + 1);
|
|
326
|
+
|
|
327
|
+
// Aliased absolute kind path — first segment carries a dot, e.g.
|
|
328
|
+
// "HttpDispatch.Outcomes/$defs/Returns". Resolves the alias through the
|
|
329
|
+
// *kind owner's* scope (not the consumer's), navigates the JSON Pointer
|
|
330
|
+
// into the resolved definition's schema, and validates each field value.
|
|
331
|
+
//
|
|
332
|
+
// Relative anchors are property names that cannot contain a dot
|
|
333
|
+
// (CEL-style identifiers), so a dot in anchorName is unambiguous.
|
|
334
|
+
if (!isAbsolute && anchorName.includes(".")) {
|
|
335
|
+
const resolvedResourceKind = aliases.resolveKind(r.kind) ?? r.kind;
|
|
336
|
+
const resourceDef =
|
|
337
|
+
registry.resolve(r.kind) ?? registry.resolve(resolvedResourceKind);
|
|
338
|
+
const owningModule = (resourceDef?.metadata as { module?: string } | undefined)?.module;
|
|
339
|
+
const ownerScope =
|
|
340
|
+
(owningModule ? aliasesByModule?.get(owningModule) : undefined) ?? aliases;
|
|
341
|
+
|
|
342
|
+
const targetKind = ownerScope.resolveKind(anchorName);
|
|
343
|
+
if (!targetKind) {
|
|
344
|
+
diagnostics.push({
|
|
345
|
+
severity: DiagnosticSeverity.Error,
|
|
346
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
347
|
+
source: SOURCE,
|
|
348
|
+
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → cannot resolve alias '${anchorName}'`,
|
|
349
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
410
353
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
354
|
+
const targetDef = registry.resolve(targetKind);
|
|
355
|
+
if (!targetDef?.schema) {
|
|
356
|
+
diagnostics.push({
|
|
357
|
+
severity: DiagnosticSeverity.Error,
|
|
358
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
359
|
+
source: SOURCE,
|
|
360
|
+
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema`,
|
|
361
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
422
365
|
|
|
423
|
-
|
|
424
|
-
if (
|
|
425
|
-
const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
|
|
426
|
-
for (const issue of issues) {
|
|
366
|
+
const subSchema = navigateJsonPointer(targetDef.schema, jsonPointer);
|
|
367
|
+
if (subSchema === undefined) {
|
|
427
368
|
diagnostics.push({
|
|
428
369
|
severity: DiagnosticSeverity.Error,
|
|
429
|
-
code: "
|
|
370
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
430
371
|
source: SOURCE,
|
|
431
|
-
message: `${resourceLabel}: '${
|
|
432
|
-
data: { resource: resourceData, filePath, path:
|
|
372
|
+
message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema path '${jsonPointer}'`,
|
|
373
|
+
data: { resource: resourceData, filePath, path: fieldPath },
|
|
433
374
|
});
|
|
375
|
+
return;
|
|
434
376
|
}
|
|
377
|
+
|
|
378
|
+
for (const { value: fieldValue, path: concretePath } of resolveFieldEntries(r, fieldPath)) {
|
|
379
|
+
if (fieldValue == null) continue;
|
|
380
|
+
const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
|
|
381
|
+
for (const issue of issues) {
|
|
382
|
+
diagnostics.push({
|
|
383
|
+
severity: DiagnosticSeverity.Error,
|
|
384
|
+
code: "DEPENDENT_SCHEMA_MISMATCH",
|
|
385
|
+
source: SOURCE,
|
|
386
|
+
message: `${resourceLabel}: '${concretePath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
|
|
387
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return;
|
|
435
392
|
}
|
|
436
|
-
continue;
|
|
437
|
-
}
|
|
438
393
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
394
|
+
// Derive the anchor path in the resource config.
|
|
395
|
+
let anchorPath: string;
|
|
396
|
+
if (isAbsolute) {
|
|
397
|
+
anchorPath = anchorName;
|
|
398
|
+
} else {
|
|
399
|
+
// Relative: replace the last dot-segment of fieldPath with anchorName.
|
|
400
|
+
// e.g. "nodes[].options" → "nodes[].backend"
|
|
401
|
+
const lastDot = fieldPath.lastIndexOf(".");
|
|
402
|
+
anchorPath = lastDot === -1 ? anchorName : fieldPath.slice(0, lastDot + 1) + anchorName;
|
|
403
|
+
}
|
|
449
404
|
|
|
450
|
-
|
|
451
|
-
|
|
405
|
+
const anchorValues = resolveFieldValues(r, anchorPath);
|
|
406
|
+
if (anchorValues.length === 0) return; // anchor field not set — nothing to validate
|
|
452
407
|
|
|
453
|
-
|
|
408
|
+
const fieldEntries = resolveFieldEntries(r, fieldPath);
|
|
454
409
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
410
|
+
for (let i = 0; i < fieldEntries.length; i++) {
|
|
411
|
+
const { value: fieldValue, path: concretePath } = fieldEntries[i];
|
|
412
|
+
if (fieldValue == null) continue;
|
|
458
413
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
414
|
+
// For absolute paths, the single anchor applies to all field values.
|
|
415
|
+
const anchorVal = isAbsolute ? anchorValues[0] : anchorValues[i];
|
|
416
|
+
if (!anchorVal || typeof anchorVal !== "object") continue;
|
|
462
417
|
|
|
463
|
-
|
|
464
|
-
|
|
418
|
+
const refVal = anchorVal as Record<string, unknown>;
|
|
419
|
+
if (typeof refVal.kind !== "string") continue;
|
|
465
420
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
421
|
+
const refResolvedKind = aliases.resolveKind(refVal.kind) ?? refVal.kind;
|
|
422
|
+
const refDef = registry.resolve(refVal.kind) ?? registry.resolve(refResolvedKind);
|
|
423
|
+
if (!refDef?.schema) {
|
|
424
|
+
diagnostics.push({
|
|
425
|
+
severity: DiagnosticSeverity.Error,
|
|
426
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
427
|
+
source: SOURCE,
|
|
428
|
+
message: `${resourceLabel}: x-telo-schema-from at '${concretePath}' → kind '${refVal.kind}' has no schema`,
|
|
429
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
430
|
+
});
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
478
433
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
434
|
+
const subSchema = navigateJsonPointer(refDef.schema, jsonPointer);
|
|
435
|
+
if (subSchema === undefined) {
|
|
436
|
+
diagnostics.push({
|
|
437
|
+
severity: DiagnosticSeverity.Error,
|
|
438
|
+
code: "SCHEMA_FROM_MISSING_PATH",
|
|
439
|
+
source: SOURCE,
|
|
440
|
+
message: `${resourceLabel}: x-telo-schema-from at '${concretePath}' → kind '${refVal.kind}' has no schema path '${jsonPointer}'`,
|
|
441
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
442
|
+
});
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
490
445
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
446
|
+
const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
|
|
447
|
+
for (const issue of issues) {
|
|
448
|
+
diagnostics.push({
|
|
449
|
+
severity: DiagnosticSeverity.Error,
|
|
450
|
+
code: "DEPENDENT_SCHEMA_MISMATCH",
|
|
451
|
+
source: SOURCE,
|
|
452
|
+
message: `${resourceLabel}: '${concretePath}' does not match schema from '${refVal.kind}${jsonPointer}': ${issue}`,
|
|
453
|
+
data: { resource: resourceData, filePath, path: concretePath },
|
|
454
|
+
});
|
|
455
|
+
}
|
|
500
456
|
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
{ aliases, aliasesByModule, skipKinds: SYSTEM_KINDS, expand: false },
|
|
460
|
+
);
|
|
504
461
|
|
|
505
462
|
return diagnostics;
|
|
506
463
|
}
|