@telorun/analyzer 0.64.0 → 0.65.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/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +19 -3
- package/dist/definition-registry.d.ts +23 -6
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +41 -13
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/schema-compat.d.ts +59 -22
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +60 -75
- package/dist/schema-error-report.d.ts +68 -0
- package/dist/schema-error-report.d.ts.map +1 -0
- package/dist/schema-error-report.js +356 -0
- package/dist/telo-version.d.ts +1 -1
- package/dist/telo-version.js +1 -1
- package/dist/validate-nested-inline.d.ts +22 -1
- package/dist/validate-nested-inline.d.ts.map +1 -1
- package/dist/validate-nested-inline.js +17 -9
- package/dist/validate-step-inputs.js +11 -3
- package/package.json +2 -2
- package/src/analyzer.ts +22 -2
- package/src/definition-registry.ts +48 -11
- package/src/index.ts +10 -2
- package/src/schema-compat.ts +92 -79
- package/src/schema-error-report.ts +417 -0
- package/src/telo-version.ts +1 -1
- package/src/validate-nested-inline.ts +35 -14
- package/src/validate-step-inputs.ts +10 -4
|
@@ -8,10 +8,19 @@ import {
|
|
|
8
8
|
isSchemaFromEntry,
|
|
9
9
|
type ReferenceFieldMap,
|
|
10
10
|
} from "./reference-field-map.js";
|
|
11
|
-
import { createAjv,
|
|
11
|
+
import { createAjv, navigateJsonPointer } from "./schema-compat.js";
|
|
12
|
+
import {
|
|
13
|
+
formatSingleError,
|
|
14
|
+
reduceSchemaErrors,
|
|
15
|
+
schemaIssues,
|
|
16
|
+
type SchemaIssue,
|
|
17
|
+
} from "./schema-error-report.js";
|
|
12
18
|
import { effectiveAuthorSchema } from "./extends-resolution.js";
|
|
13
19
|
|
|
14
20
|
/** Pure kind → ResourceDefinition map. No controller loading, no lifecycle. */
|
|
21
|
+
/** What `ajv.compile` hands back: a predicate carrying its own `errors`. */
|
|
22
|
+
type CompiledValidator = ((data: unknown) => boolean) & { errors?: any[] | null };
|
|
23
|
+
|
|
15
24
|
export class DefinitionRegistry {
|
|
16
25
|
constructor() {
|
|
17
26
|
for (const def of KERNEL_BUILTINS) this.register(def);
|
|
@@ -22,6 +31,7 @@ export class DefinitionRegistry {
|
|
|
22
31
|
* across analyze() calls and no unbounded growth across the process lifetime. */
|
|
23
32
|
private readonly ajv = createAjv();
|
|
24
33
|
private readonly registeredSchemaIds = new Set<string>();
|
|
34
|
+
private readonly compiledValidators = new WeakMap<Record<string, any>, CompiledValidator>();
|
|
25
35
|
/** The subset of `registeredSchemaIds` claimed by a kind's schema. Kinds and
|
|
26
36
|
* named `Telo.Type`s share one `telo://<module>/<Name>` id space, so this is
|
|
27
37
|
* what lets a colliding type name be reported instead of silently dropped. */
|
|
@@ -162,20 +172,47 @@ export class DefinitionRegistry {
|
|
|
162
172
|
return schema && typeof schema === "object" ? (schema as Record<string, any>) : undefined;
|
|
163
173
|
}
|
|
164
174
|
|
|
165
|
-
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
175
|
+
/**
|
|
176
|
+
* Validates a resource's configuration against its kind's schema, with the
|
|
177
|
+
* offending field's path — what a `SCHEMA_VIOLATION` diagnostic is built from.
|
|
178
|
+
*
|
|
179
|
+
* On THIS registry's AJV, which is the point: it holds every registered
|
|
180
|
+
* definition schema and every named `Telo.Type`, so a kind whose schema
|
|
181
|
+
* references a shape declared elsewhere is checked rather than skipped. The
|
|
182
|
+
* module-level instance this used to run on had none of them registered, so
|
|
183
|
+
* such a schema failed to compile and the failure was swallowed — a resource
|
|
184
|
+
* could be arbitrarily wrong and `telo check` reported nothing, while the
|
|
185
|
+
* kernel (whose validator does resolve the reference) rejected it at boot.
|
|
186
|
+
* Two AJVs answering one question is what made that possible; there is now
|
|
187
|
+
* one, and it is the same one `schemaCompileError` reports through.
|
|
188
|
+
*/
|
|
170
189
|
validateWithRefs(data: unknown, schema: Record<string, any>): string[] {
|
|
171
|
-
|
|
190
|
+
const validate = this.compiledFor(schema);
|
|
191
|
+
if (!validate || validate(data)) return [];
|
|
192
|
+
return reduceSchemaErrors(validate.errors).map(formatSingleError);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** {@link validateWithRefs}, with the path each issue is anchored at. */
|
|
196
|
+
validateResourceConfig(data: unknown, schema: Record<string, any>): SchemaIssue[] {
|
|
197
|
+
const validate = this.compiledFor(schema);
|
|
198
|
+
if (!validate || validate(data)) return [];
|
|
199
|
+
return schemaIssues(validate.errors);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Memoized per schema OBJECT — the analyzer validates every resource of a
|
|
203
|
+
* kind against the same one, and this runs at keystroke time in an editor.
|
|
204
|
+
* A schema AJV refuses compiles to `undefined`; that is reported once,
|
|
205
|
+
* anchored on the owning definition, by `schemaCompileError`. */
|
|
206
|
+
private compiledFor(schema: Record<string, any>): CompiledValidator | undefined {
|
|
207
|
+
const cached = this.compiledValidators.get(schema);
|
|
208
|
+
if (cached) return cached;
|
|
172
209
|
try {
|
|
173
|
-
validate = this.ajv.compile(schema);
|
|
210
|
+
const validate = this.ajv.compile(schema);
|
|
211
|
+
this.compiledValidators.set(schema, validate);
|
|
212
|
+
return validate;
|
|
174
213
|
} catch {
|
|
175
|
-
return
|
|
214
|
+
return undefined;
|
|
176
215
|
}
|
|
177
|
-
if (validate(data)) return [];
|
|
178
|
-
return (validate.errors ?? []).map(formatSingleError);
|
|
179
216
|
}
|
|
180
217
|
|
|
181
218
|
/** Returns the AJV compile error for `schema`, or `undefined` when it compiles.
|
package/src/index.ts
CHANGED
|
@@ -247,8 +247,16 @@ export { validateDynamicSelectors, validateRefSlotDeclarations } from "./validat
|
|
|
247
247
|
export type { RefSlotIssue } from "./validate-ref-slots.js";
|
|
248
248
|
export { validateValueTypeSlots } from "./validate-value-type-slots.js";
|
|
249
249
|
export type { ValueTypeSlotIssue } from "./validate-value-type-slots.js";
|
|
250
|
-
export { checkSchemaCompatibility, selectUnionBranch } from "./schema-compat.js";
|
|
251
|
-
export type { CompatibilityResult } from "./schema-compat.js";
|
|
250
|
+
export { checkSchemaCompatibility, resolveRefIn, selectUnionBranch } from "./schema-compat.js";
|
|
251
|
+
export type { CompatibilityResult, ExternalSchemaResolver } from "./schema-compat.js";
|
|
252
|
+
export {
|
|
253
|
+
ajvErrorToPath,
|
|
254
|
+
formatAjvErrors,
|
|
255
|
+
formatSingleError,
|
|
256
|
+
reduceSchemaErrors,
|
|
257
|
+
schemaIssues,
|
|
258
|
+
} from "./schema-error-report.js";
|
|
259
|
+
export type { AjvErrorLike, SchemaIssue } from "./schema-error-report.js";
|
|
252
260
|
export { visitManifest } from "./manifest-visitor.js";
|
|
253
261
|
export type {
|
|
254
262
|
CelSiteEvent,
|
package/src/schema-compat.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
valueTypePlaceholder,
|
|
15
15
|
} from "@telorun/sdk";
|
|
16
16
|
import { ManifestRootSchema } from "./manifest-schemas.js";
|
|
17
|
+
import { schemaIssues, type SchemaIssue } from "./schema-error-report.js";
|
|
17
18
|
import { registerTeloKeywords } from "./value-type-keyword.js";
|
|
18
19
|
|
|
19
20
|
const Ajv = (AjvModule as any).default ?? AjvModule;
|
|
@@ -255,55 +256,8 @@ function compare(
|
|
|
255
256
|
}
|
|
256
257
|
}
|
|
257
258
|
|
|
258
|
-
export
|
|
259
|
-
|
|
260
|
-
const params = err.params ?? {};
|
|
261
|
-
switch (err.keyword) {
|
|
262
|
-
case "additionalProperties":
|
|
263
|
-
return `${p} must NOT have additional properties ('${params.additionalProperty}' is not allowed)`;
|
|
264
|
-
case "required":
|
|
265
|
-
return `${p} is missing required property '${params.missingProperty}'`;
|
|
266
|
-
case "enum":
|
|
267
|
-
return `${p} ${err.message ?? "is invalid"} (${(params.allowedValues as unknown[])?.join(" | ")})`;
|
|
268
|
-
case "type":
|
|
269
|
-
return `${p} must be ${params.type} (got ${typeof err.data})`;
|
|
270
|
-
default:
|
|
271
|
-
return `${p} ${err.message ?? "is invalid"}`;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export function formatAjvErrors(errors: any[] | null | undefined): string {
|
|
276
|
-
if (!errors || errors.length === 0) return "Unknown schema error";
|
|
277
|
-
return errors.map(formatSingleError).join("; ");
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** Converts an AJV error object to a dotted path string compatible with PositionIndex keys.
|
|
281
|
-
* e.g. instancePath "/config/routes/0/handler" → "config.routes[0].handler"
|
|
282
|
-
* For "required" keyword errors, appends the missing property to the parent path. */
|
|
283
|
-
function ajvErrorToPath(err: any): string {
|
|
284
|
-
const instancePath = (err.instancePath ?? "") as string;
|
|
285
|
-
const parts = instancePath.split("/").filter((p) => p !== "");
|
|
286
|
-
let result = "";
|
|
287
|
-
for (const part of parts) {
|
|
288
|
-
if (/^\d+$/.test(part)) {
|
|
289
|
-
result += `[${part}]`;
|
|
290
|
-
} else {
|
|
291
|
-
result += result ? `.${part}` : part;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
if (err.keyword === "required" && err.params?.missingProperty) {
|
|
295
|
-
const missing = err.params.missingProperty as string;
|
|
296
|
-
result += result ? `.${missing}` : missing;
|
|
297
|
-
}
|
|
298
|
-
return result;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** A schema validation issue with a dotted-path pointer to the offending field. */
|
|
302
|
-
export interface SchemaIssue {
|
|
303
|
-
message: string;
|
|
304
|
-
/** Dotted path to the field (e.g. "config.handler"). Empty string means root. */
|
|
305
|
-
path: string;
|
|
306
|
-
}
|
|
259
|
+
export { formatAjvErrors, formatSingleError } from "./schema-error-report.js";
|
|
260
|
+
export type { SchemaIssue } from "./schema-error-report.js";
|
|
307
261
|
|
|
308
262
|
/** Does `schema` compile as-authored? Used to tell a malformed module schema
|
|
309
263
|
* (the author's problem) apart from a fault we introduced while normalizing it. */
|
|
@@ -335,10 +289,7 @@ export function validateAgainstSchema(data: unknown, schema: Record<string, any>
|
|
|
335
289
|
compiledSchemaValidators.set(schema, validate);
|
|
336
290
|
}
|
|
337
291
|
if (validate(data)) return [];
|
|
338
|
-
return (validate.errors
|
|
339
|
-
message: formatSingleError(err),
|
|
340
|
-
path: ajvErrorToPath(err),
|
|
341
|
-
}));
|
|
292
|
+
return schemaIssues(validate.errors);
|
|
342
293
|
}
|
|
343
294
|
|
|
344
295
|
/** Resolves a JSON Pointer (RFC 6901, must start with "/") into a schema object.
|
|
@@ -640,16 +591,55 @@ function objectPlaceholder(schema: Record<string, any>): Record<string, unknown>
|
|
|
640
591
|
|
|
641
592
|
const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
|
|
642
593
|
|
|
643
|
-
/**
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
594
|
+
/**
|
|
595
|
+
* Resolve a `$ref` — the document-local `#/$defs/...` form against `root`, and
|
|
596
|
+
* anything else through `external` when a caller supplies one.
|
|
597
|
+
*
|
|
598
|
+
* A named shape is addressed by a registered id (`telo:<module>/<Type>`), which
|
|
599
|
+
* lives in a schema store rather than in this document, so without the hook a
|
|
600
|
+
* walk stops at the reference and treats a described value as undescribed:
|
|
601
|
+
* every CEL leaf under it is handed the schema-unaware `""` placeholder and
|
|
602
|
+
* then rejected against a branch it was never measured against. The caller
|
|
603
|
+
* supplies the store because only the caller has one.
|
|
604
|
+
*/
|
|
605
|
+
export function resolveRef(
|
|
606
|
+
schema: Record<string, any>,
|
|
607
|
+
root: Record<string, any>,
|
|
608
|
+
external?: ExternalSchemaResolver,
|
|
609
|
+
): Record<string, any> {
|
|
610
|
+
return resolveRefIn(schema, root, external).schema;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* {@link resolveRef}, reporting the ROOT the result's own `#/...` references
|
|
615
|
+
* resolve against.
|
|
616
|
+
*
|
|
617
|
+
* Following an external reference enters another document, and a `$ref` inside
|
|
618
|
+
* it is relative to THAT document — which is the whole of how a shape declares
|
|
619
|
+
* its own vocabulary (`anyOf: [{$ref: "#/$defs/Text"}, …]`). Resolving those
|
|
620
|
+
* against the referring document finds nothing, and a walker that then treats
|
|
621
|
+
* the branches as unconstrained accepts every one of them, resolves the union
|
|
622
|
+
* to nothing, and hands the values underneath an untyped stand-in. So the base
|
|
623
|
+
* travels with the schema.
|
|
624
|
+
*/
|
|
625
|
+
export function resolveRefIn(
|
|
626
|
+
schema: Record<string, any>,
|
|
627
|
+
root: Record<string, any>,
|
|
628
|
+
external?: ExternalSchemaResolver,
|
|
629
|
+
): { schema: Record<string, any>; root: Record<string, any> } {
|
|
630
|
+
if (!schema.$ref || typeof schema.$ref !== "string") return { schema, root };
|
|
631
|
+
if (schema.$ref === "#") return { schema: root, root };
|
|
632
|
+
if (schema.$ref.startsWith("#/$defs/")) {
|
|
633
|
+
const resolved = root.$defs?.[schema.$ref.slice("#/$defs/".length)];
|
|
634
|
+
return resolved ? { schema: resolved, root } : { schema, root };
|
|
635
|
+
}
|
|
636
|
+
const target = external?.(schema.$ref);
|
|
637
|
+
return target ? { schema: target, root: target } : { schema, root };
|
|
651
638
|
}
|
|
652
639
|
|
|
640
|
+
/** Looks a registered schema up by its `$id`. */
|
|
641
|
+
export type ExternalSchemaResolver = (ref: string) => Record<string, any> | undefined;
|
|
642
|
+
|
|
653
643
|
/** Collect property schemas from top-level `properties` and all `oneOf`/`anyOf` sub-schemas. */
|
|
654
644
|
/**
|
|
655
645
|
* The `oneOf` / `anyOf` branch a value is written against, when exactly one fits.
|
|
@@ -671,6 +661,7 @@ export function selectUnionBranch(
|
|
|
671
661
|
schema: Record<string, any>,
|
|
672
662
|
data: unknown,
|
|
673
663
|
root: Record<string, any>,
|
|
664
|
+
external?: ExternalSchemaResolver,
|
|
674
665
|
): Record<string, any> {
|
|
675
666
|
const branches = (schema.oneOf ?? schema.anyOf) as Record<string, any>[] | undefined;
|
|
676
667
|
if (!Array.isArray(branches) || branches.length === 0) return schema;
|
|
@@ -692,7 +683,7 @@ export function selectUnionBranch(
|
|
|
692
683
|
if (!kind) return schema;
|
|
693
684
|
|
|
694
685
|
const fits = branches
|
|
695
|
-
.map((b) => resolveRef(b, root))
|
|
686
|
+
.map((b) => resolveRef(b, root, external))
|
|
696
687
|
.filter((b) => {
|
|
697
688
|
const types = Array.isArray(b.type) ? b.type : b.type ? [b.type] : [];
|
|
698
689
|
if (types.length > 0 && !types.includes(kind)) return false;
|
|
@@ -733,10 +724,13 @@ export function collectProperties(schema: Record<string, any>): Record<string, a
|
|
|
733
724
|
|
|
734
725
|
/** Deep-clone `data`, replacing every pure CEL template string (`${{ expr }}`) with a
|
|
735
726
|
* schema-appropriate placeholder so AJV can validate non-CEL fields without false positives. */
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
727
|
+
/** Everything {@link substituteCelFields} does beyond walking the value.
|
|
728
|
+
*
|
|
729
|
+
* One object rather than trailing positionals: the resolver is the parameter a
|
|
730
|
+
* caller most needs and was the LAST of six, so reaching it meant counting
|
|
731
|
+
* `undefined`s — and a caller that stopped counting one short simply got the
|
|
732
|
+
* old blind behaviour, silently. Two of them did. */
|
|
733
|
+
export interface SubstituteOptions {
|
|
740
734
|
/** Called with the dotted path of every value replaced by a placeholder.
|
|
741
735
|
*
|
|
742
736
|
* A placeholder is a stand-in for something only known at runtime, so its
|
|
@@ -746,11 +740,28 @@ export function substituteCelFields(
|
|
|
746
740
|
* so making every placeholder acceptable is not achievable in general —
|
|
747
741
|
* knowing where not to look is. Structural findings survive because they are
|
|
748
742
|
* located at the CONTAINER, not at the substituted leaf. */
|
|
749
|
-
onSubstitute?: (path: string) => void
|
|
750
|
-
path
|
|
743
|
+
onSubstitute?: (path: string) => void;
|
|
744
|
+
/** Dotted path of `data` within the resource, for `onSubstitute`. */
|
|
745
|
+
path?: string;
|
|
746
|
+
/** Resolves a named shape (`telo:<module>/<Type>`) to its schema. Without it
|
|
747
|
+
* a slot described by one reads as undescribed and every CEL leaf beneath it
|
|
748
|
+
* is handed the typeless `""` stand-in — which the shape then rejects, so a
|
|
749
|
+
* perfectly valid expression is reported as a violation. */
|
|
750
|
+
external?: ExternalSchemaResolver;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export function substituteCelFields(
|
|
754
|
+
data: unknown,
|
|
755
|
+
schema: Record<string, any>,
|
|
756
|
+
rootSchema?: Record<string, any>,
|
|
757
|
+
options: SubstituteOptions = {},
|
|
751
758
|
): unknown {
|
|
752
|
-
const
|
|
753
|
-
const
|
|
759
|
+
const { onSubstitute, external } = options;
|
|
760
|
+
const path = options.path ?? "";
|
|
761
|
+
const base = rootSchema ?? schema;
|
|
762
|
+
const entered = resolveRefIn(schema, base, external);
|
|
763
|
+
const root = entered.root;
|
|
764
|
+
const resolved = selectUnionBranch(entered.schema, data, root, external);
|
|
754
765
|
const mark = () => onSubstitute?.(path);
|
|
755
766
|
|
|
756
767
|
if (typeof data === "string" && CEL_PURE_RE.test(data)) {
|
|
@@ -789,9 +800,13 @@ export function substituteCelFields(
|
|
|
789
800
|
return celPlaceholderForSchema(resolved);
|
|
790
801
|
}
|
|
791
802
|
if (Array.isArray(data)) {
|
|
792
|
-
const
|
|
793
|
-
return data.map((
|
|
794
|
-
substituteCelFields(
|
|
803
|
+
const item = resolveRefIn((resolved.items ?? {}) as Record<string, any>, root, external);
|
|
804
|
+
return data.map((element, i) =>
|
|
805
|
+
substituteCelFields(element, item.schema, item.root, {
|
|
806
|
+
onSubstitute,
|
|
807
|
+
path: `${path}[${i}]`,
|
|
808
|
+
external,
|
|
809
|
+
}),
|
|
795
810
|
);
|
|
796
811
|
}
|
|
797
812
|
if (data !== null && typeof data === "object") {
|
|
@@ -802,13 +817,11 @@ export function substituteCelFields(
|
|
|
802
817
|
: undefined;
|
|
803
818
|
const result: Record<string, unknown> = {};
|
|
804
819
|
for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
|
|
805
|
-
result[k] = substituteCelFields(
|
|
806
|
-
v,
|
|
807
|
-
(props[k] ?? addlProps ?? {}) as Record<string, any>,
|
|
808
|
-
root,
|
|
820
|
+
result[k] = substituteCelFields(v, (props[k] ?? addlProps ?? {}) as Record<string, any>, root, {
|
|
809
821
|
onSubstitute,
|
|
810
|
-
path ? `${path}.${k}` : k,
|
|
811
|
-
|
|
822
|
+
path: path ? `${path}.${k}` : k,
|
|
823
|
+
external,
|
|
824
|
+
});
|
|
812
825
|
}
|
|
813
826
|
return result;
|
|
814
827
|
}
|