@telorun/analyzer 0.57.0 → 0.58.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 +91 -9
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +6 -3
- package/dist/definition-registry.d.ts +5 -0
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +9 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/invocation-contract.d.ts +21 -13
- package/dist/invocation-contract.d.ts.map +1 -1
- package/dist/invocation-contract.js +43 -46
- package/dist/loaded-types.d.ts +13 -0
- package/dist/loaded-types.d.ts.map +1 -1
- package/dist/manifest-loader.d.ts +8 -1
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +33 -6
- package/dist/migrations/driver.d.ts +69 -0
- package/dist/migrations/driver.d.ts.map +1 -0
- package/dist/migrations/driver.js +200 -0
- package/dist/migrations/entries/index.d.ts +3 -0
- package/dist/migrations/entries/index.d.ts.map +1 -0
- package/dist/migrations/entries/index.js +9 -0
- package/dist/migrations/entries/normalize-value-types.json +48 -0
- package/dist/migrations/entries/ref-slot-scalar-type.json +20 -0
- package/dist/migrations/entry-data.d.ts +25 -0
- package/dist/migrations/entry-data.d.ts.map +1 -0
- package/dist/migrations/entry-data.js +171 -0
- package/dist/migrations/index.d.ts +22 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +19 -0
- package/dist/migrations/match.d.ts +162 -0
- package/dist/migrations/match.d.ts.map +1 -0
- package/dist/migrations/match.js +295 -0
- package/dist/migrations/patch.d.ts +68 -0
- package/dist/migrations/patch.d.ts.map +1 -0
- package/dist/migrations/patch.js +236 -0
- package/dist/migrations/provenance.d.ts +25 -0
- package/dist/migrations/provenance.d.ts.map +1 -0
- package/dist/migrations/provenance.js +118 -0
- package/dist/migrations/registry.d.ts +35 -0
- package/dist/migrations/registry.d.ts.map +1 -0
- package/dist/migrations/registry.js +35 -0
- package/dist/migrations/report.d.ts +28 -0
- package/dist/migrations/report.d.ts.map +1 -0
- package/dist/migrations/report.js +144 -0
- package/dist/migrations/types.d.ts +115 -0
- package/dist/migrations/types.d.ts.map +1 -0
- package/dist/migrations/types.js +24 -0
- package/dist/migrations/yaml-edit.d.ts +21 -0
- package/dist/migrations/yaml-edit.d.ts.map +1 -0
- package/dist/migrations/yaml-edit.js +245 -0
- package/dist/parse-loaded-file.d.ts +8 -0
- package/dist/parse-loaded-file.d.ts.map +1 -1
- package/dist/parse-loaded-file.js +22 -13
- package/dist/resolve-schema-type-refs.d.ts +29 -12
- package/dist/resolve-schema-type-refs.d.ts.map +1 -1
- package/dist/resolve-schema-type-refs.js +89 -16
- package/dist/schema-compat.d.ts +35 -9
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +180 -64
- package/dist/schema-region.d.ts +33 -0
- package/dist/schema-region.d.ts.map +1 -0
- package/dist/schema-region.js +45 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +29 -3
- package/dist/validate-step-inputs.d.ts +8 -1
- package/dist/validate-step-inputs.d.ts.map +1 -1
- package/dist/validate-step-inputs.js +47 -2
- package/dist/validate-value-type-slots.d.ts +43 -0
- package/dist/validate-value-type-slots.d.ts.map +1 -0
- package/dist/validate-value-type-slots.js +142 -0
- package/dist/value-type-keyword.d.ts +60 -0
- package/dist/value-type-keyword.d.ts.map +1 -0
- package/dist/value-type-keyword.js +119 -0
- package/dist/yaml-source-edit.d.ts +56 -0
- package/dist/yaml-source-edit.d.ts.map +1 -0
- package/dist/yaml-source-edit.js +93 -0
- package/package.json +3 -3
- package/src/analyzer.ts +99 -6
- package/src/cel-environment.ts +6 -3
- package/src/definition-registry.ts +10 -0
- package/src/index.ts +26 -2
- package/src/invocation-contract.ts +43 -48
- package/src/loaded-types.ts +13 -0
- package/src/manifest-loader.ts +37 -6
- package/src/migrations/driver.ts +262 -0
- package/src/migrations/entries/index.ts +10 -0
- package/src/migrations/entries/normalize-value-types.json +48 -0
- package/src/migrations/entries/ref-slot-scalar-type.json +20 -0
- package/src/migrations/entry-data.ts +207 -0
- package/src/migrations/index.ts +22 -0
- package/src/migrations/match.ts +382 -0
- package/src/migrations/patch.ts +265 -0
- package/src/migrations/provenance.ts +167 -0
- package/src/migrations/registry.ts +40 -0
- package/src/migrations/report.ts +169 -0
- package/src/migrations/types.ts +144 -0
- package/src/migrations/yaml-edit.ts +260 -0
- package/src/parse-loaded-file.ts +31 -12
- package/src/resolve-schema-type-refs.ts +93 -16
- package/src/schema-compat.ts +212 -74
- package/src/schema-region.ts +46 -0
- package/src/types.ts +17 -0
- package/src/validate-cel-context.ts +36 -3
- package/src/validate-step-inputs.ts +56 -1
- package/src/validate-value-type-slots.ts +182 -0
- package/src/value-type-keyword.ts +135 -0
- package/src/yaml-source-edit.ts +106 -0
- package/dist/binary-slot.d.ts +0 -39
- package/dist/binary-slot.d.ts.map +0 -1
- package/dist/binary-slot.js +0 -58
- package/src/binary-slot.ts +0 -71
package/src/schema-compat.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import AjvModule from "ajv";
|
|
2
2
|
import addFormats from "ajv-formats";
|
|
3
3
|
import {
|
|
4
|
-
INCLUDE_BYTES_ENGINE,
|
|
5
|
-
INCLUDE_ENGINE_NAMES,
|
|
6
4
|
isRefSentinel,
|
|
7
5
|
isTaggedSentinel,
|
|
8
6
|
ManifestRootSchema,
|
|
9
|
-
|
|
7
|
+
producedTypeOf,
|
|
10
8
|
} from "@telorun/templating";
|
|
11
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
celBaseOfValueType,
|
|
11
|
+
celTypeOfValueType,
|
|
12
|
+
readValueTypeSlot,
|
|
13
|
+
valueBrandBases,
|
|
14
|
+
valueTypeOf,
|
|
15
|
+
valueTypePlaceholder,
|
|
16
|
+
} from "@telorun/sdk";
|
|
17
|
+
import { registerTeloKeywords } from "./value-type-keyword.js";
|
|
12
18
|
|
|
13
19
|
const Ajv = (AjvModule as any).default ?? AjvModule;
|
|
14
20
|
|
|
@@ -25,10 +31,11 @@ export function createAjv(): InstanceType<typeof Ajv> {
|
|
|
25
31
|
(addFormats as any).default
|
|
26
32
|
? (addFormats as any).default(instance)
|
|
27
33
|
: (addFormats as any)(instance);
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
34
|
+
// One registration site for every Telo keyword — the annotations as no-ops and
|
|
35
|
+
// `x-telo-type` as the one that checks. Registered here and in the kernel's
|
|
36
|
+
// validators from one definition, so a literal at an instance-typed slot is
|
|
37
|
+
// rejected statically and at dispatch by the identical rule.
|
|
38
|
+
registerTeloKeywords(instance);
|
|
32
39
|
instance.addSchema(ManifestRootSchema);
|
|
33
40
|
return instance;
|
|
34
41
|
}
|
|
@@ -41,62 +48,170 @@ export interface CompatibilityResult {
|
|
|
41
48
|
issues: string[];
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
51
|
+
/**
|
|
52
|
+
* Conservative structural JSON Schema compatibility check — is a value shaped
|
|
53
|
+
* like `source` acceptable where `target` is declared?
|
|
54
|
+
*
|
|
55
|
+
* COVARIANT, because the values this compares are consumed by reading: a
|
|
56
|
+
* narrower element satisfies a slot declaring a wider one. Only DEFINITE
|
|
57
|
+
* mismatches are flagged — a missing required field, a primitive type conflict,
|
|
58
|
+
* a disagreeing type argument. Anything ambiguous (`anyOf` / `oneOf` / `allOf`,
|
|
59
|
+
* an absent `type`, an undeclared argument) is treated as compatible, so an
|
|
60
|
+
* unmigrated producer and consumer keep checking exactly as they did.
|
|
61
|
+
*
|
|
62
|
+
* The traversal is written here rather than reused: the function this replaced
|
|
63
|
+
* compared only `type` for the names in `target.required` and descended only
|
|
64
|
+
* into objects, so a stream of arrays of strings and a stream of arrays of
|
|
65
|
+
* integers both read as `array` and passed — leaving argument checking inert on
|
|
66
|
+
* exactly the nested shapes it exists for. What survives from it is its posture.
|
|
67
|
+
*
|
|
68
|
+
* `resolveRef` sees through a named shape. Declaring a shape once and
|
|
69
|
+
* referencing it is the sanctioned way to reuse one, so without it two such
|
|
70
|
+
* arguments present as opaque nodes carrying no information — the same reason
|
|
71
|
+
* {@link withLiveValuesSkipped} takes one.
|
|
72
|
+
*/
|
|
47
73
|
export function checkSchemaCompatibility(
|
|
48
74
|
source: Record<string, any>,
|
|
49
75
|
target: Record<string, any>,
|
|
76
|
+
resolveRef?: (ref: string) => Record<string, any> | undefined,
|
|
50
77
|
): CompatibilityResult {
|
|
51
78
|
const issues: string[] = [];
|
|
52
|
-
|
|
79
|
+
compare(source, target, "", issues, resolveRef, new Set());
|
|
53
80
|
return { compatible: issues.length === 0, issues };
|
|
54
81
|
}
|
|
55
82
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
83
|
+
type RefResolver = ((ref: string) => Record<string, any> | undefined) | undefined;
|
|
84
|
+
|
|
85
|
+
function deref(schema: Record<string, any>, resolveRef: RefResolver): Record<string, any> {
|
|
86
|
+
if (!resolveRef || typeof schema.$ref !== "string") return schema;
|
|
87
|
+
return resolveRef(schema.$ref) ?? schema;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function compare(
|
|
91
|
+
rawSource: Record<string, any>,
|
|
92
|
+
rawTarget: Record<string, any>,
|
|
59
93
|
path: string,
|
|
60
94
|
issues: string[],
|
|
95
|
+
resolveRef: RefResolver,
|
|
96
|
+
seen: Set<string>,
|
|
61
97
|
): void {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
98
|
+
if (!rawSource || !rawTarget || typeof rawSource !== "object" || typeof rawTarget !== "object") {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// A recursive shape reached through the same pair of references twice is the
|
|
102
|
+
// same question again; answering it once terminates and loses nothing.
|
|
103
|
+
//
|
|
104
|
+
// The key is the REFERENCE PAIR and deliberately not the path. A path grows on
|
|
105
|
+
// every descent, so a key containing it is new every time and the guard never
|
|
106
|
+
// fires — which is a stack overflow on the first self-referential shape, taking
|
|
107
|
+
// every other diagnostic in the file with it. It also has to be this way to be
|
|
108
|
+
// correct rather than merely terminating: comparing two schemas gives the same
|
|
109
|
+
// answer wherever they are reached from, so the second visit has nothing to add.
|
|
110
|
+
if (typeof rawSource.$ref === "string" && typeof rawTarget.$ref === "string") {
|
|
111
|
+
const key = `${rawSource.$ref}|${rawTarget.$ref}`;
|
|
112
|
+
if (seen.has(key)) return;
|
|
113
|
+
seen.add(key);
|
|
114
|
+
}
|
|
115
|
+
const source = deref(rawSource, resolveRef);
|
|
116
|
+
const target = deref(rawTarget, resolveRef);
|
|
117
|
+
|
|
118
|
+
// Value types first: an `instance` representation has no JSON `type` to
|
|
119
|
+
// compare, so its identity IS the comparison — and its arguments are where the
|
|
120
|
+
// real information lives.
|
|
121
|
+
const sourceType = readValueTypeSlot(source);
|
|
122
|
+
const targetType = readValueTypeSlot(target);
|
|
123
|
+
if (sourceType && targetType) {
|
|
124
|
+
if (sourceType.name !== targetType.name) {
|
|
125
|
+
issues.push(
|
|
126
|
+
`${path || "/"}: value type mismatch — source is '${sourceType.name}', target expects '${targetType.name}'`,
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
70
129
|
}
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
130
|
+
for (const [argument, targetArg] of Object.entries(targetType.args)) {
|
|
131
|
+
const sourceArg = sourceType.args[argument];
|
|
132
|
+
// An omitted argument is *any*, in BOTH directions. That is what keeps a
|
|
133
|
+
// bare `Telo.Stream` flowing into a typed slot and vice versa, so nothing
|
|
134
|
+
// that does not declare its element is forced to.
|
|
135
|
+
if (sourceArg === undefined) continue;
|
|
136
|
+
compare(
|
|
137
|
+
sourceArg as Record<string, any>,
|
|
138
|
+
targetArg as Record<string, any>,
|
|
139
|
+
`${path}<${argument}>`,
|
|
140
|
+
issues,
|
|
141
|
+
resolveRef,
|
|
142
|
+
seen,
|
|
143
|
+
);
|
|
75
144
|
}
|
|
145
|
+
return;
|
|
76
146
|
}
|
|
77
|
-
}
|
|
78
147
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
148
|
+
// One side declares a value type and the other does not. A `json`
|
|
149
|
+
// representation refines a base type, so it is compared through that base — a
|
|
150
|
+
// `Telo.TcpPort` into a plain `integer` slot is gradual typing working. An
|
|
151
|
+
// `instance` is not JSON at all, so ANY declared JSON type on the other side is
|
|
152
|
+
// a definite conflict; a side declaring no type at all is still saying nothing
|
|
153
|
+
// and stays compatible.
|
|
154
|
+
if (Boolean(sourceType) !== Boolean(targetType)) {
|
|
155
|
+
const declared = (sourceType ?? targetType)!;
|
|
156
|
+
const other = sourceType ? target : source;
|
|
157
|
+
if (declared.entry && typeof other.type === "string") {
|
|
158
|
+
const base = celBaseOfValueType(declared.entry);
|
|
159
|
+
const asJson = base === undefined ? undefined : declared.entry.base;
|
|
160
|
+
if (asJson !== other.type) {
|
|
161
|
+
issues.push(
|
|
162
|
+
`${path || "/"}: value type mismatch — ${
|
|
163
|
+
sourceType ? "source is" : "target expects"
|
|
164
|
+
} '${declared.name}', ${sourceType ? "target expects" : "source is"} '${other.type}'`,
|
|
165
|
+
);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Only flag definite primitive type clashes; an absent or union `type` says
|
|
172
|
+
// too little to judge.
|
|
86
173
|
if (
|
|
87
|
-
source.type &&
|
|
88
|
-
target.type &&
|
|
89
174
|
typeof source.type === "string" &&
|
|
90
175
|
typeof target.type === "string" &&
|
|
91
176
|
source.type !== target.type
|
|
92
177
|
) {
|
|
93
178
|
issues.push(
|
|
94
|
-
`${path}: type mismatch — source is '${source.type}', target expects '${target.type}'`,
|
|
179
|
+
`${path || "/"}: type mismatch — source is '${source.type}', target expects '${target.type}'`,
|
|
95
180
|
);
|
|
96
181
|
return;
|
|
97
182
|
}
|
|
98
|
-
if (
|
|
99
|
-
|
|
183
|
+
if (source.anyOf || source.oneOf || source.allOf) return;
|
|
184
|
+
if (target.anyOf || target.oneOf || target.allOf) return;
|
|
185
|
+
|
|
186
|
+
// An array's element, which the old comparison never looked at — so every
|
|
187
|
+
// nested shape passed regardless of what it contained.
|
|
188
|
+
if (target.items && source.items) {
|
|
189
|
+
compare(
|
|
190
|
+
source.items as Record<string, any>,
|
|
191
|
+
target.items as Record<string, any>,
|
|
192
|
+
`${path}[]`,
|
|
193
|
+
issues,
|
|
194
|
+
resolveRef,
|
|
195
|
+
seen,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const targetRequired: string[] = Array.isArray(target.required) ? target.required : [];
|
|
200
|
+
const sourceProps: Record<string, any> = source.properties ?? {};
|
|
201
|
+
const targetProps: Record<string, any> = target.properties ?? {};
|
|
202
|
+
for (const field of targetRequired) {
|
|
203
|
+
if (!(field in sourceProps)) {
|
|
204
|
+
// Only when the source describes an object at all: a schema with no
|
|
205
|
+
// `properties` is saying nothing about its shape, not saying it is empty.
|
|
206
|
+
if (source.properties === undefined) continue;
|
|
207
|
+
issues.push(`${path}/${field}: required by target but missing from source`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const srcProp = sourceProps[field];
|
|
211
|
+
const tgtProp = targetProps[field];
|
|
212
|
+
if (tgtProp && srcProp) {
|
|
213
|
+
compare(srcProp, tgtProp, `${path}/${field}`, issues, resolveRef, seen);
|
|
214
|
+
}
|
|
100
215
|
}
|
|
101
216
|
}
|
|
102
217
|
|
|
@@ -165,13 +280,8 @@ function schemaCompiles(schema: Record<string, any>): boolean {
|
|
|
165
280
|
export function validateAgainstSchema(data: unknown, schema: Record<string, any>): SchemaIssue[] {
|
|
166
281
|
let validate = compiledSchemaValidators.get(schema);
|
|
167
282
|
if (!validate) {
|
|
168
|
-
// Normalize outside the try: a fault in our own ref-slot normalization must
|
|
169
|
-
// surface, never be mistaken for the module author's schema being malformed.
|
|
170
|
-
// Drop the legacy scalar `type` an older published module may still pin on
|
|
171
|
-
// its `x-telo-ref` slots so a resolved reference object validates.
|
|
172
|
-
const normalized = normalizeRefSlots(schema) as Record<string, any>;
|
|
173
283
|
try {
|
|
174
|
-
validate = ajv.compile(
|
|
284
|
+
validate = ajv.compile(schema);
|
|
175
285
|
} catch (err) {
|
|
176
286
|
// The normalized schema didn't compile. If the original schema is itself
|
|
177
287
|
// malformed, that is the module author's error — already surfaced once,
|
|
@@ -236,28 +346,36 @@ export function navigateSchemaToExprPath(
|
|
|
236
346
|
}
|
|
237
347
|
|
|
238
348
|
/**
|
|
239
|
-
*
|
|
349
|
+
* Every `json`-represented value type's CEL brand → the primitive it refines.
|
|
350
|
+
*
|
|
240
351
|
* A brand is a nominal type the analyzer registers (see cel-environment.ts) so
|
|
241
|
-
* structurally-identical values (a `TcpPort` and a `UdpPort` are both
|
|
242
|
-
* stay distinct for static wiring checks. Brands carry no runtime
|
|
243
|
-
* value flows as its base type.
|
|
352
|
+
* structurally-identical values (a `Telo.TcpPort` and a `Telo.UdpPort` are both
|
|
353
|
+
* integers) stay distinct for static wiring checks. Brands carry no runtime
|
|
354
|
+
* effect — the value flows as its base type.
|
|
355
|
+
*
|
|
356
|
+
* DERIVED from the value-type vocabulary, never hand-written: a new brand is a
|
|
357
|
+
* new entry file, and a table here would be a second place to edit that could
|
|
358
|
+
* silently disagree with the one the runtime reads.
|
|
244
359
|
*/
|
|
245
|
-
export const VALUE_BRAND_BASE: Record<string, string> =
|
|
246
|
-
TcpPort: "int",
|
|
247
|
-
UdpPort: "int",
|
|
248
|
-
};
|
|
360
|
+
export const VALUE_BRAND_BASE: Record<string, string> = valueBrandBases();
|
|
249
361
|
|
|
250
|
-
/** Read a
|
|
362
|
+
/** Read a `json`-represented value type's brand off a schema, or undefined.
|
|
363
|
+
* An `instance` type is not a brand — it replaces the JSON layer rather than
|
|
364
|
+
* refining it, so it carries its binding's CEL type instead. */
|
|
251
365
|
export function brandOfSchema(schema: Record<string, any> | undefined): string | undefined {
|
|
252
|
-
const
|
|
253
|
-
return
|
|
366
|
+
const entry = valueTypeOf(schema);
|
|
367
|
+
return entry && entry.representation === "json" ? entry.name : undefined;
|
|
254
368
|
}
|
|
255
369
|
|
|
256
370
|
/** Map a JSON Schema type annotation to a CEL type string. */
|
|
257
371
|
export function jsonSchemaToCelType(schema: Record<string, any> | undefined): string {
|
|
258
372
|
if (!schema || typeof schema !== "object") return "dyn";
|
|
259
|
-
|
|
260
|
-
|
|
373
|
+
// A declared value type IS the type — for an `instance` representation it is
|
|
374
|
+
// the only thing that says so, since bytes and streams have no JSON Schema
|
|
375
|
+
// type at all. Before the three annotations were unified, a byte slot's
|
|
376
|
+
// expression typed as `dyn` because nothing here consulted `x-telo-binary`.
|
|
377
|
+
const entry = valueTypeOf(schema);
|
|
378
|
+
if (entry) return celTypeOfValueType(entry);
|
|
261
379
|
if (schema.anyOf || schema.oneOf || schema.allOf) return "dyn";
|
|
262
380
|
if (Array.isArray(schema.type)) return "dyn";
|
|
263
381
|
switch (schema.type) {
|
|
@@ -296,6 +414,16 @@ export function celTypeSatisfiesJsonSchema(celType: string, schema: Record<strin
|
|
|
296
414
|
if (fieldBrand) return fieldBrand === celType;
|
|
297
415
|
celType = sourceBase;
|
|
298
416
|
}
|
|
417
|
+
// An `instance` representation has no JSON Schema type to compare against, so
|
|
418
|
+
// an expression carrying its binding's CEL type is accepted on that ground
|
|
419
|
+
// alone. This ADDS a case and never removes one: a mismatch falls through to
|
|
420
|
+
// the rules below rather than being rejected here, so nothing that checks
|
|
421
|
+
// today stops checking, and the `bytes` row still accepts a byte expression at
|
|
422
|
+
// a plain `type: string` slot.
|
|
423
|
+
const slotEntry = valueTypeOf(schema);
|
|
424
|
+
if (slotEntry?.representation === "instance" && celTypeOfValueType(slotEntry) === celType) {
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
299
427
|
if (!schema.type && !schema.anyOf && !schema.oneOf && !schema.allOf) return true;
|
|
300
428
|
if (schema.anyOf || schema.oneOf || schema.allOf) return true;
|
|
301
429
|
const schemaTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
@@ -383,11 +511,15 @@ function foldedConstraints(schema: Record<string, any>): Record<string, any> {
|
|
|
383
511
|
|
|
384
512
|
export function celPlaceholderForSchema(rawSchema: Record<string, any>): unknown {
|
|
385
513
|
const schema = foldedConstraints(rawSchema);
|
|
386
|
-
//
|
|
387
|
-
// and at dispatch, so a CEL leaf standing in for a runtime
|
|
388
|
-
// it. This is what keeps the rule single — a literal is
|
|
389
|
-
// literal is a
|
|
390
|
-
|
|
514
|
+
// An instance-typed slot's placeholder must BE an instance: the same keyword
|
|
515
|
+
// validates statically and at dispatch, so a CEL leaf standing in for a runtime
|
|
516
|
+
// value has to satisfy it. This is what keeps the rule single — a literal is
|
|
517
|
+
// rejected because no YAML literal is a byte buffer, while a value arriving by
|
|
518
|
+
// reference passes. The stand-in comes from the binding table, so a new
|
|
519
|
+
// instance type brings its own rather than adding a branch here; a `live` type
|
|
520
|
+
// declares none, because nothing validates it.
|
|
521
|
+
const placeholder = valueTypePlaceholder(schema);
|
|
522
|
+
if (placeholder !== undefined) return placeholder;
|
|
391
523
|
if (schema.default !== undefined) return schema.default;
|
|
392
524
|
// An enum-constrained field needs a placeholder drawn from the enum: the
|
|
393
525
|
// type-based fallbacks below ("" for a string, 0 for a number) satisfy `type`
|
|
@@ -571,16 +703,22 @@ export function substituteCelFields(
|
|
|
571
703
|
if (isRefSentinel(data)) {
|
|
572
704
|
return data;
|
|
573
705
|
}
|
|
574
|
-
// A
|
|
575
|
-
//
|
|
576
|
-
// produces
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
// real type lets AJV and the `x-telo-
|
|
581
|
-
// statically, with no new diagnostic code.
|
|
582
|
-
|
|
583
|
-
|
|
706
|
+
// A tag whose produced type is a CONSTANT of the tag rather than a function of
|
|
707
|
+
// the slot substitutes a placeholder of THAT type: `!include-text` always
|
|
708
|
+
// produces a string and `!include-bytes` always produces bytes. Collapsing
|
|
709
|
+
// them to a slot-shaped placeholder like a CEL expression would make every
|
|
710
|
+
// slot accept both, so a byte embed at a `type: string` field passed
|
|
711
|
+
// `telo check` and failed at resource creation — and the reverse did too.
|
|
712
|
+
// Substituting the real type lets AJV and the `x-telo-type` keyword reject
|
|
713
|
+
// both directions statically, with no new diagnostic code.
|
|
714
|
+
//
|
|
715
|
+
// The engine is what says so. This used to branch on two tag names, which was
|
|
716
|
+
// the only place a tag's produced type was written down and it was written in
|
|
717
|
+
// the consumer — so a future tag producing bytes had to be added to a set here
|
|
718
|
+
// rather than declaring it.
|
|
719
|
+
if (isTaggedSentinel(data)) {
|
|
720
|
+
const produced = producedTypeOf(data.engine);
|
|
721
|
+
if (produced) return celPlaceholderForSchema(produced);
|
|
584
722
|
}
|
|
585
723
|
if (isTaggedSentinel(data)) {
|
|
586
724
|
mark();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where author-written JSON Schema lives in a manifest — one definition, read by
|
|
3
|
+
* every surface that has to bound itself to schema.
|
|
4
|
+
*
|
|
5
|
+
* The keys are the KERNEL's own schema-valued manifest keys, which no resource
|
|
6
|
+
* kind owns. That is what makes the rule generic: a surface using it learns no
|
|
7
|
+
* resource kind, and a module that invents a schema-bearing field of its own
|
|
8
|
+
* reaches it through one of these or not at all — against the topology-driven
|
|
9
|
+
* constraint, an enumeration of the standard library's kinds would be both
|
|
10
|
+
* incomplete and knowledge the analyzer must not hold.
|
|
11
|
+
*
|
|
12
|
+
* A schema fragment is NOT confined to kind documents. An inline `inputType:` /
|
|
13
|
+
* `outputType:` sits on any kind that declares one, an API route carries
|
|
14
|
+
* `request.schema.body`, a `Telo.JsonSchema` carries `schema`. So a check that
|
|
15
|
+
* walks a manifest's ROOT keys covers a fraction of the sites an author writes —
|
|
16
|
+
* which is a silent hole in exactly the checks that exist to stop a silent
|
|
17
|
+
* degrade. Containment is by ANCESTRY instead: a node is in a schema region when
|
|
18
|
+
* some key on the path to it is one of these.
|
|
19
|
+
*
|
|
20
|
+
* Browser-safe: no Node built-ins.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The kernel's schema-valued manifest keys. */
|
|
24
|
+
export const SCHEMA_REGION_KEYS: readonly string[] = [
|
|
25
|
+
"schema",
|
|
26
|
+
"status",
|
|
27
|
+
"inputType",
|
|
28
|
+
"outputType",
|
|
29
|
+
"itemType",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True when `path` reaches into a schema region — some ANCESTOR segment is a
|
|
34
|
+
* schema-valued key.
|
|
35
|
+
*
|
|
36
|
+
* Ancestors only, so a rule keyed on a region key itself still means "inside a
|
|
37
|
+
* schema" rather than "is one". `path` is the walk's own segment list; numeric
|
|
38
|
+
* segments (array indices) never equal a key name, so they need no special case.
|
|
39
|
+
*/
|
|
40
|
+
export function isInSchemaRegion(path: readonly (string | number)[]): boolean {
|
|
41
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
42
|
+
const segment = path[i];
|
|
43
|
+
if (typeof segment === "string" && SCHEMA_REGION_KEYS.includes(segment)) return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -110,12 +110,29 @@ export interface LoadOptions {
|
|
|
110
110
|
* pairs manifests to YAML nodes by index. Folded into the file cache key so a
|
|
111
111
|
* desugared and a raw load of the same file never collide. */
|
|
112
112
|
desugarImports?: boolean;
|
|
113
|
+
/** When true, the loader's migration phase rewrites legacy spellings in each
|
|
114
|
+
* parsed document before anything else reads the tree. On for every resolved
|
|
115
|
+
* consumer — the kernel's analysis and runtime loads, `telo check`, the
|
|
116
|
+
* analyzer — so one rewrite serves the definition registry, the runtime and
|
|
117
|
+
* the editor's analysis alike.
|
|
118
|
+
*
|
|
119
|
+
* **Off for a round-trip view.** The editor pairs manifests to YAML nodes by
|
|
120
|
+
* index and writes the pair back on save; migrating one half of that pair
|
|
121
|
+
* would silently change the author's file. `telo migrate` is likewise a raw
|
|
122
|
+
* consumer — it rewrites the YAML itself and must see the legacy spelling to
|
|
123
|
+
* find it. Folded into the file cache key so a migrated and a raw load of
|
|
124
|
+
* the same file never collide. */
|
|
125
|
+
migrate?: boolean;
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
export interface LoaderInitOptions {
|
|
116
129
|
/** Handlers for CEL stdlib functions (e.g. `sha256`). Analyzer-only callers may
|
|
117
130
|
* omit this and get throwing stubs; runtime callers (kernel) must supply real impls. */
|
|
118
131
|
celHandlers?: import("./cel-environment.js").CelHandlers;
|
|
132
|
+
/** Migration set for `LoadOptions.migrate` loads. Defaults to the analyzer's
|
|
133
|
+
* own `CORE_MIGRATIONS`. A host supplies its own once module-shipped entries
|
|
134
|
+
* are aggregated alongside the core ones. */
|
|
135
|
+
migrations?: readonly import("./migrations/types.js").MigrationEntry[];
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
export interface AnalysisOptions {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
|
|
2
|
-
import { mergeTypeSchemas } from "@telorun/sdk";
|
|
2
|
+
import { mergeTypeSchemas, parseCanonicalTypeSchemaId } from "@telorun/sdk";
|
|
3
3
|
import { KERNEL_BUILTINS } from "./builtins.js";
|
|
4
4
|
|
|
5
5
|
export interface ContextResolveOpts {
|
|
@@ -89,6 +89,38 @@ export function resolveTypeFieldToSchema(
|
|
|
89
89
|
|
|
90
90
|
if (typeof value === "object" && value !== null) {
|
|
91
91
|
const obj = value as Record<string, any>;
|
|
92
|
+
// A canonical, module-scoped id — what `resolveSchemaTypeRefs` normalizes
|
|
93
|
+
// both the reference tag and the legacy `telo://` authority form into.
|
|
94
|
+
//
|
|
95
|
+
// This is the ALIAS-AWARE path, and it is why the canonical form exists:
|
|
96
|
+
// the id names the OWNING MODULE, so two libraries declaring a shape of the
|
|
97
|
+
// same name stay distinct. The bare-name lookup below cannot do that — it
|
|
98
|
+
// matches the first manifest with that `metadata.name` in a flattened list,
|
|
99
|
+
// which is how an alias got silently dropped.
|
|
100
|
+
const canonical = parseCanonicalTypeSchemaId(obj.$ref);
|
|
101
|
+
if (canonical) {
|
|
102
|
+
if (ancestry.has(obj.$ref)) return undefined;
|
|
103
|
+
const owned = allManifests.find(
|
|
104
|
+
(m) =>
|
|
105
|
+
(m.metadata as any)?.name === canonical.typeName &&
|
|
106
|
+
(m.metadata as any)?.module === canonical.moduleName &&
|
|
107
|
+
isTypeKind(m.kind, allManifests) &&
|
|
108
|
+
typeof m.schema === "object" &&
|
|
109
|
+
m.schema !== null,
|
|
110
|
+
);
|
|
111
|
+
if (owned) {
|
|
112
|
+
return applyExtends(
|
|
113
|
+
owned.schema as Record<string, any>,
|
|
114
|
+
owned.extends,
|
|
115
|
+
allManifests,
|
|
116
|
+
new Set(ancestry).add(obj.$ref),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
// A module that is not in this set — a single-file analysis, or an import
|
|
120
|
+
// whose library was not forwarded. Fall through to the bare name, which is
|
|
121
|
+
// what this resolved before the canonical form existed.
|
|
122
|
+
return resolveTypeFieldToSchema(canonical.typeName, allManifests, ancestry);
|
|
123
|
+
}
|
|
92
124
|
// Inline type resource: { kind: "Type.JsonSchema", schema: {...} }
|
|
93
125
|
if (obj.schema && typeof obj.schema === "object") {
|
|
94
126
|
return applyExtends(obj.schema as Record<string, any>, obj.extends, allManifests, ancestry);
|
|
@@ -97,8 +129,9 @@ export function resolveTypeFieldToSchema(
|
|
|
97
129
|
if (obj.type || obj.properties) {
|
|
98
130
|
return obj;
|
|
99
131
|
}
|
|
100
|
-
// Named type reference resolved from a `!ref` → { kind, name }
|
|
101
|
-
//
|
|
132
|
+
// Named type reference resolved from a `!ref` → { kind, name }. Reached only
|
|
133
|
+
// where the canonical rewrite could not run (a scope whose aliases are not in
|
|
134
|
+
// hand); resolves by bare name, as it always has.
|
|
102
135
|
if (typeof obj.name === "string") {
|
|
103
136
|
return resolveTypeFieldToSchema(obj.name, allManifests, ancestry);
|
|
104
137
|
}
|
|
@@ -2,7 +2,14 @@ import type { AliasResolver, ModuleScopes } from "./alias-resolver.js";
|
|
|
2
2
|
import type { DefinitionRegistry } from "./definition-registry.js";
|
|
3
3
|
import type { ContractDirection } from "./extends-resolution.js";
|
|
4
4
|
import { resolveContract } from "./invocation-contract.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
checkSchemaCompatibility,
|
|
7
|
+
navigateSchemaToExprPath,
|
|
8
|
+
substituteCelFields,
|
|
9
|
+
validateAgainstSchema,
|
|
10
|
+
} from "./schema-compat.js";
|
|
11
|
+
import { plainChainOf } from "@telorun/templating";
|
|
12
|
+
import { valueTypeOf } from "@telorun/sdk";
|
|
6
13
|
import {
|
|
7
14
|
analyzerContractScope,
|
|
8
15
|
containerOf,
|
|
@@ -16,8 +23,12 @@ export interface StepInputIssue {
|
|
|
16
23
|
path: string;
|
|
17
24
|
targetLabel: string;
|
|
18
25
|
message: string;
|
|
26
|
+
/** Set when the issue is a type-argument disagreement rather than a contract
|
|
27
|
+
* shape violation — the two read differently and deserve their own code. */
|
|
28
|
+
code?: "CEL_TYPE_ARGUMENT_MISMATCH";
|
|
19
29
|
}
|
|
20
30
|
|
|
31
|
+
|
|
21
32
|
/**
|
|
22
33
|
* Validate every step's `inputs:` against the invoked target's declared input
|
|
23
34
|
* contract — the static half of what the kernel enforces at dispatch.
|
|
@@ -40,6 +51,10 @@ export function collectStepInputIssues(
|
|
|
40
51
|
defs: DefinitionRegistry,
|
|
41
52
|
aliases: AliasResolver,
|
|
42
53
|
scopes: ModuleScopes,
|
|
54
|
+
/** The typed `steps.<name>.result` context for this resource. Supplied by the
|
|
55
|
+
* caller because building it is analyzer state; without it the contract check
|
|
56
|
+
* still runs and only the type-argument comparison is skipped. */
|
|
57
|
+
stepContext?: Record<string, any>,
|
|
43
58
|
): StepInputIssue[] {
|
|
44
59
|
const out: StepInputIssue[] = [];
|
|
45
60
|
const props = defSchema.properties as Record<string, any> | undefined;
|
|
@@ -97,6 +112,46 @@ export function collectStepInputIssues(
|
|
|
97
112
|
const substituted = substituteCelFields(values, contract.schema, undefined, (p) =>
|
|
98
113
|
celPaths.add(p),
|
|
99
114
|
);
|
|
115
|
+
// The type-argument check, at the one site where a produced value's schema
|
|
116
|
+
// meets a consuming slot's. A CEL leaf's placeholder says nothing about
|
|
117
|
+
// what the expression yields, so AJV above is silent here by design — and
|
|
118
|
+
// that silence is exactly where a stream of the wrong element used to
|
|
119
|
+
// flow. The comparison is covariant and gradual: an omitted argument is
|
|
120
|
+
// *any* in both directions, so only a definite conflict is reported.
|
|
121
|
+
if (stepContext) {
|
|
122
|
+
for (const [inputName, inputValue] of Object.entries(values)) {
|
|
123
|
+
const chain = plainChainOf(inputValue);
|
|
124
|
+
// The step context is rooted at the STEP MAP, so a `steps.` prefix is
|
|
125
|
+
// the namespace name and not a property of it. Only that namespace is
|
|
126
|
+
// navigated: `inputs.` and a named binding resolve elsewhere, and
|
|
127
|
+
// guessing at a root this does not hold would compare the wrong schema.
|
|
128
|
+
if (!chain?.startsWith("steps.")) continue;
|
|
129
|
+
const produced = navigateSchemaToExprPath(stepContext, chain.slice("steps.".length));
|
|
130
|
+
const slotSchema = (contract.schema.properties as Record<string, any> | undefined)?.[
|
|
131
|
+
inputName
|
|
132
|
+
];
|
|
133
|
+
if (!produced || !slotSchema) continue;
|
|
134
|
+
// ONLY a type-argument disagreement, which is what the code says. The
|
|
135
|
+
// comparator is a general structural comparison, so running it on any
|
|
136
|
+
// pair would report a missing required property as "disagreeing type
|
|
137
|
+
// arguments" — and would turn every plain-chain wiring site into a
|
|
138
|
+
// broad new Error-severity check hidden behind an argument-specific
|
|
139
|
+
// name. Both sides must declare a value type for the question to be
|
|
140
|
+
// about arguments at all.
|
|
141
|
+
if (!valueTypeOf(produced) || !valueTypeOf(slotSchema)) continue;
|
|
142
|
+
const { compatible, issues } = checkSchemaCompatibility(produced, slotSchema, (ref) =>
|
|
143
|
+
defs.schemaForId(ref),
|
|
144
|
+
);
|
|
145
|
+
if (compatible) continue;
|
|
146
|
+
out.push({
|
|
147
|
+
path: `${stepPath}.${inputsField}.${inputName}`,
|
|
148
|
+
targetLabel: invokedName ?? invokedKind ?? "the invoked resource",
|
|
149
|
+
message: issues.join("; "),
|
|
150
|
+
code: "CEL_TYPE_ARGUMENT_MISMATCH",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
100
155
|
for (const issue of validateAgainstSchema(substituted, contract.schema)) {
|
|
101
156
|
if (celPaths.has(issue.path)) continue;
|
|
102
157
|
// A missing-required issue names the property that ISN'T there, so
|