@telorun/analyzer 0.63.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/analysis-registry.d.ts +24 -0
- package/dist/analysis-registry.d.ts.map +1 -1
- package/dist/analysis-registry.js +35 -0
- package/dist/analyzer.d.ts +3 -37
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +76 -470
- package/dist/cel-scope-query.d.ts +109 -0
- package/dist/cel-scope-query.d.ts.map +1 -0
- package/dist/cel-scope-query.js +270 -0
- package/dist/cel-scope.d.ts +166 -0
- package/dist/cel-scope.d.ts.map +1 -0
- package/dist/cel-scope.js +377 -0
- package/dist/definition-registry.d.ts +38 -6
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +66 -22
- package/dist/find-manifest.d.ts +10 -0
- package/dist/find-manifest.d.ts.map +1 -0
- package/dist/find-manifest.js +12 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/invocation-contract.d.ts +11 -0
- package/dist/invocation-contract.d.ts.map +1 -1
- package/dist/invocation-contract.js +15 -0
- package/dist/manifest-analysis.d.ts +73 -0
- package/dist/manifest-analysis.d.ts.map +1 -0
- package/dist/manifest-analysis.js +78 -0
- package/dist/manifest-path.d.ts +18 -0
- package/dist/manifest-path.d.ts.map +1 -0
- package/dist/manifest-path.js +37 -0
- 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/schema-walk.d.ts +25 -0
- package/dist/schema-walk.d.ts.map +1 -0
- package/dist/schema-walk.js +126 -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.d.ts +17 -0
- package/dist/validate-step-inputs.d.ts.map +1 -1
- package/dist/validate-step-inputs.js +108 -9
- package/package.json +2 -2
- package/src/analysis-registry.ts +37 -0
- package/src/analyzer.ts +83 -587
- package/src/cel-scope-query.ts +337 -0
- package/src/cel-scope.ts +570 -0
- package/src/definition-registry.ts +79 -24
- package/src/find-manifest.ts +19 -0
- package/src/index.ts +23 -2
- package/src/invocation-contract.ts +22 -0
- package/src/manifest-analysis.ts +132 -0
- package/src/manifest-path.ts +34 -0
- package/src/schema-compat.ts +92 -79
- package/src/schema-error-report.ts +417 -0
- package/src/schema-walk.ts +144 -0
- package/src/telo-version.ts +1 -1
- package/src/validate-nested-inline.ts +35 -14
- package/src/validate-step-inputs.ts +153 -11
|
@@ -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.
|
|
@@ -321,35 +358,53 @@ export class DefinitionRegistry {
|
|
|
321
358
|
return expanded;
|
|
322
359
|
}
|
|
323
360
|
|
|
324
|
-
|
|
361
|
+
/**
|
|
362
|
+
* The schema an `x-telo-schema-from` slot derives its shape from.
|
|
363
|
+
*
|
|
364
|
+
* Only the STATIC form resolves: an anchor that is a dotted alias-qualified
|
|
365
|
+
* kind (`HttpDispatch.Request/$defs/Matcher`). The polymorphic forms — a
|
|
366
|
+
* relative anchor, or a single-segment absolute one — name a sibling property
|
|
367
|
+
* whose value is known per resource, so a definition-level lookup would be
|
|
368
|
+
* guessing at one instance's shape.
|
|
369
|
+
*
|
|
370
|
+
* Its own method because a schema-from slot is otherwise INVISIBLE to anything
|
|
371
|
+
* reading `properties`: the field map needs the nested ref slots, and an IDE
|
|
372
|
+
* needs the very same node to offer a key or describe one. Two resolutions of
|
|
373
|
+
* one annotation would eventually disagree about which anchors are static.
|
|
374
|
+
*/
|
|
375
|
+
resolveSchemaFromNode(
|
|
325
376
|
schemaFrom: string,
|
|
326
|
-
fieldPath: string,
|
|
327
377
|
ownerScope: AliasResolver,
|
|
328
|
-
):
|
|
378
|
+
): Record<string, any> | undefined {
|
|
329
379
|
const isAbsolute = schemaFrom.startsWith("/");
|
|
330
380
|
const expr = isAbsolute ? schemaFrom.slice(1) : schemaFrom;
|
|
331
381
|
const slashIdx = expr.indexOf("/");
|
|
332
|
-
if (slashIdx === -1) return
|
|
382
|
+
if (slashIdx === -1) return undefined;
|
|
333
383
|
const anchorName = expr.slice(0, slashIdx);
|
|
334
384
|
const jsonPointer = "/" + expr.slice(slashIdx + 1);
|
|
335
385
|
|
|
336
|
-
|
|
337
|
-
// "HttpDispatch.Outcomes/$defs/Returns"). Polymorphic forms — relative
|
|
338
|
-
// anchors or single-segment absolute anchors — only resolve once we know a
|
|
339
|
-
// sibling property's value, which is per-resource.
|
|
340
|
-
if (!anchorName.includes(".")) return null;
|
|
386
|
+
if (!anchorName.includes(".")) return undefined;
|
|
341
387
|
|
|
342
388
|
const targetKind = ownerScope.resolveKind(anchorName);
|
|
343
|
-
if (!targetKind) return
|
|
389
|
+
if (!targetKind) return undefined;
|
|
344
390
|
const targetDef = this.resolve(targetKind);
|
|
345
|
-
if (!targetDef?.schema) return
|
|
391
|
+
if (!targetDef?.schema) return undefined;
|
|
346
392
|
const subSchema = navigateJsonPointer(
|
|
347
393
|
targetDef.schema as Record<string, unknown>,
|
|
348
394
|
jsonPointer,
|
|
349
395
|
);
|
|
350
|
-
if (!subSchema || typeof subSchema !== "object") return
|
|
396
|
+
if (!subSchema || typeof subSchema !== "object") return undefined;
|
|
397
|
+
return subSchema as Record<string, any>;
|
|
398
|
+
}
|
|
351
399
|
|
|
352
|
-
|
|
400
|
+
private resolveSchemaFromSubMap(
|
|
401
|
+
schemaFrom: string,
|
|
402
|
+
fieldPath: string,
|
|
403
|
+
ownerScope: AliasResolver,
|
|
404
|
+
): ReferenceFieldMap | null {
|
|
405
|
+
const subSchema = this.resolveSchemaFromNode(schemaFrom, ownerScope);
|
|
406
|
+
if (!subSchema) return null;
|
|
407
|
+
return buildFieldMapAtPath(subSchema, fieldPath);
|
|
353
408
|
}
|
|
354
409
|
|
|
355
410
|
/** Returns all definitions that transitively extend the given abstract kind.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The manifest a `(kind, name)` pair addresses.
|
|
5
|
+
*
|
|
6
|
+
* One implementation, because two consumers needed it and a manifest set is
|
|
7
|
+
* exactly the thing `ManifestAnalysis` exists to have one answer over. Undefined
|
|
8
|
+
* when the set holds no such resource — a document the author is still writing.
|
|
9
|
+
*/
|
|
10
|
+
export function findManifest(
|
|
11
|
+
manifests: readonly ResourceManifest[],
|
|
12
|
+
kind: string | undefined,
|
|
13
|
+
name: string | undefined,
|
|
14
|
+
): ResourceManifest | undefined {
|
|
15
|
+
if (!kind || !name) return undefined;
|
|
16
|
+
return manifests.find(
|
|
17
|
+
(m) => m.kind === kind && (m.metadata as { name?: string } | undefined)?.name === name,
|
|
18
|
+
);
|
|
19
|
+
}
|
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,
|
|
@@ -370,6 +378,19 @@ export { documentToAst, parseToAst } from "./yaml-ast.js";
|
|
|
370
378
|
export type { AstDocument, AstMap, AstNode, AstPair, AstScalar, AstSeq } from "./yaml-ast.js";
|
|
371
379
|
export { CelParseError, buildCelSegments, wrapCelAst } from "./cel-ast.js";
|
|
372
380
|
export type { CelNode, CelSegment } from "./cel-ast.js";
|
|
381
|
+
|
|
382
|
+
// The CEL scope rule, and the way into it from outside the analysis pass. What
|
|
383
|
+
// completes, what hovers and what type-checks are one answer because they are
|
|
384
|
+
// one function.
|
|
385
|
+
export { CelScopeResolver } from "./cel-scope.js";
|
|
386
|
+
export type { CelScope, CelScopeInputs, CelSiteRef } from "./cel-scope.js";
|
|
387
|
+
export { CelScopeQuery } from "./cel-scope-query.js";
|
|
388
|
+
export type { CelScopeQueryContext, ContextDeclarationSite } from "./cel-scope-query.js";
|
|
389
|
+
// The pairing of a registry and the manifests it analyzed — the one seam a host
|
|
390
|
+
// threads for every question that needs both.
|
|
391
|
+
export { navigateConcretePath } from "./manifest-path.js";
|
|
392
|
+
export { ManifestAnalysis } from "./manifest-analysis.js";
|
|
393
|
+
export type { ManifestRef } from "./manifest-analysis.js";
|
|
373
394
|
export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity, diagnosticFix } from "./types.js";
|
|
374
395
|
export type {
|
|
375
396
|
AnalysisDiagnostic,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { isLiveSlot, type ResourceDefinition, valueTypeOf } from "@telorun/sdk";
|
|
2
|
+
import { AliasResolver, moduleScopedDefResolver, type ModuleScopes } from "./alias-resolver.js";
|
|
3
|
+
import { DefinitionRegistry } from "./definition-registry.js";
|
|
2
4
|
import {
|
|
3
5
|
type ContractDirection,
|
|
4
6
|
contractDeclarer,
|
|
@@ -14,6 +16,26 @@ import {
|
|
|
14
16
|
|
|
15
17
|
export type { ContractDirection };
|
|
16
18
|
|
|
19
|
+
/** The {@link ContractScope} the analyzer resolves invocation contracts in: kinds
|
|
20
|
+
* resolve in the module that declared the definition they were read off (so an
|
|
21
|
+
* `extends` chain crossing module boundaries re-scopes at every hop), and named
|
|
22
|
+
* `telo#Type` references resolve against the flattened manifest list. `resolveIn`
|
|
23
|
+
* is the top-level entry point, where the kind was written by the READING
|
|
24
|
+
* module and there is no declaring definition yet. */
|
|
25
|
+
export function analyzerContractScope(
|
|
26
|
+
defs: DefinitionRegistry,
|
|
27
|
+
aliases: AliasResolver,
|
|
28
|
+
scopes: ModuleScopes,
|
|
29
|
+
allManifests: Record<string, any>[],
|
|
30
|
+
): ContractScope & { resolveIn(kind: string, module?: string): ResourceDefinition | undefined } {
|
|
31
|
+
const resolve = moduleScopedDefResolver<ResourceDefinition>(defs, aliases, scopes);
|
|
32
|
+
return {
|
|
33
|
+
resolveDefinition: resolve,
|
|
34
|
+
resolveIn: resolve.in,
|
|
35
|
+
typeManifestsFor: () => allManifests,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
/**
|
|
18
40
|
* The one answer to "what is this target's input / output schema".
|
|
19
41
|
*
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **One analyzed manifest set, and the questions asked of it.**
|
|
3
|
+
*
|
|
4
|
+
* Several answers a host needs require the SAME two things: the registry's
|
|
5
|
+
* definitions and aliases, and the manifest set they were resolved against. The
|
|
6
|
+
* registry deliberately holds no manifests — it is populated per analysis and
|
|
7
|
+
* reused across them — so each such answer would otherwise become another
|
|
8
|
+
* factory on the registry and another optional parameter on every IDE entry
|
|
9
|
+
* point. Four of those arrived in short order (CEL scope, step declarations,
|
|
10
|
+
* context-binding declarations, invocation contracts) and the next one is not
|
|
11
|
+
* hypothetical.
|
|
12
|
+
*
|
|
13
|
+
* So the pairing is named once and the questions hang off it. A host threads ONE
|
|
14
|
+
* object and gains later questions for free; each facet keeps its own honest
|
|
15
|
+
* name rather than accreting onto whichever one happened to exist first.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here re-implements an answer. `contractFor` is the shared
|
|
18
|
+
* {@link resolveContract} — the one `telo check` runs and the kernel binds at
|
|
19
|
+
* dispatch — given the scope to run in; `celScope` is the same
|
|
20
|
+
* {@link CelScopeQuery} the analysis pass's rule is built from. That is the
|
|
21
|
+
* whole point: a completion list is a claim about what the checker accepts, and
|
|
22
|
+
* a second implementation of any of these could not be held to it.
|
|
23
|
+
*/
|
|
24
|
+
import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
|
|
25
|
+
import { AliasResolver, type ModuleScopes } from "./alias-resolver.js";
|
|
26
|
+
import { CelScopeQuery, type CelScopeQueryContext } from "./cel-scope-query.js";
|
|
27
|
+
import { DefinitionRegistry } from "./definition-registry.js";
|
|
28
|
+
import type { ContractDirection } from "./extends-resolution.js";
|
|
29
|
+
import { analyzerContractScope, resolveContract } from "./invocation-contract.js";
|
|
30
|
+
import { findManifest } from "./find-manifest.js";
|
|
31
|
+
import { isModuleKind } from "./module-kinds.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A reference as the loader leaves it — the internal `{kind, name, alias?}`
|
|
35
|
+
* shape `resolveRefSentinels` rewrites `!ref` into.
|
|
36
|
+
*/
|
|
37
|
+
export interface ManifestRef {
|
|
38
|
+
kind?: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
alias?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ManifestAnalysis {
|
|
44
|
+
private readonly scopes: ModuleScopes;
|
|
45
|
+
private celScopeQuery: CelScopeQuery | undefined;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
readonly manifests: ResourceManifest[],
|
|
49
|
+
private readonly ctx: CelScopeQueryContext,
|
|
50
|
+
) {
|
|
51
|
+
const rootModules = new Set<string>();
|
|
52
|
+
for (const m of manifests) {
|
|
53
|
+
if (isModuleKind(m.kind) && m.metadata?.name) rootModules.add(m.metadata.name as string);
|
|
54
|
+
}
|
|
55
|
+
this.scopes = { aliasesByModule: ctx.aliasesByModule, rootModules };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What CEL sees, per site. Built on first use — its indices are a function of
|
|
59
|
+
* the whole set, and a host that never opens a CEL body should not pay for
|
|
60
|
+
* them. */
|
|
61
|
+
get celScope(): CelScopeQuery {
|
|
62
|
+
return (this.celScopeQuery ??= new CelScopeQuery(this.manifests, this.ctx));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The manifest a `(kind, name)` pair addresses. */
|
|
66
|
+
resourceFor(kind: string | undefined, name: string | undefined): ResourceManifest | undefined {
|
|
67
|
+
return findManifest(this.manifests, kind, name);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The invocation contract of the resource a reference names.
|
|
72
|
+
*
|
|
73
|
+
* The shared resolver, so an editor offering a target's input keys is offering
|
|
74
|
+
* exactly what `telo check` validates that call site against and what the
|
|
75
|
+
* kernel binds at dispatch. Layered instance-first: a resource declaring its
|
|
76
|
+
* own `inputType:` narrows the kind's, which is the common case for a
|
|
77
|
+
* `Run.Sequence` used as a handler.
|
|
78
|
+
*/
|
|
79
|
+
contractFor(ref: ManifestRef, direction: ContractDirection): Record<string, any> | undefined {
|
|
80
|
+
const target = this.resolveRef(ref);
|
|
81
|
+
const definition = ref.kind ? this.definitionFor(ref.kind) : undefined;
|
|
82
|
+
if (!target && !definition) return undefined;
|
|
83
|
+
return resolveContract(
|
|
84
|
+
direction,
|
|
85
|
+
target as Record<string, any> | undefined,
|
|
86
|
+
definition,
|
|
87
|
+
analyzerContractScope(
|
|
88
|
+
this.ctx.defs,
|
|
89
|
+
this.ctx.aliases,
|
|
90
|
+
this.scopes,
|
|
91
|
+
this.manifests as Record<string, any>[],
|
|
92
|
+
),
|
|
93
|
+
)?.schema;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The manifest a reference names.
|
|
98
|
+
*
|
|
99
|
+
* An ALIAS narrows before the name does: a flattened set carries every
|
|
100
|
+
* imported library's exported instances, so two libraries exporting a `store`
|
|
101
|
+
* are two manifests with one name. Matching the alias to its target module
|
|
102
|
+
* picks the right one; where the alias resolves to nothing the name alone is
|
|
103
|
+
* used, which is what a local reference needs anyway.
|
|
104
|
+
*/
|
|
105
|
+
private resolveRef(ref: ManifestRef): ResourceManifest | undefined {
|
|
106
|
+
if (!ref.name) return undefined;
|
|
107
|
+
const byName = this.manifests.filter(
|
|
108
|
+
(m) => (m.metadata as { name?: string } | undefined)?.name === ref.name,
|
|
109
|
+
);
|
|
110
|
+
if (byName.length === 0) return undefined;
|
|
111
|
+
if (byName.length === 1) return byName[0];
|
|
112
|
+
|
|
113
|
+
const targetModule = ref.alias ? this.ctx.aliases.moduleForAlias?.(ref.alias) : undefined;
|
|
114
|
+
if (targetModule) {
|
|
115
|
+
const scoped = byName.find(
|
|
116
|
+
(m) => (m.metadata as { module?: string } | undefined)?.module === targetModule,
|
|
117
|
+
);
|
|
118
|
+
if (scoped) return scoped;
|
|
119
|
+
}
|
|
120
|
+
// Several candidates and nothing to choose between them: refusing is the
|
|
121
|
+
// honest answer, since typing a call site against the wrong resource's
|
|
122
|
+
// contract is worse than typing it against none.
|
|
123
|
+
return ref.kind ? byName.find((m) => m.kind === ref.kind) : undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private definitionFor(kind: string): ResourceDefinition | undefined {
|
|
127
|
+
const canonical = this.ctx.aliases.resolveKind(kind);
|
|
128
|
+
return this.ctx.defs.resolve(kind) ?? (canonical ? this.ctx.defs.resolve(canonical) : undefined);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type { CelScopeQueryContext, AliasResolver, DefinitionRegistry };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigating a MANIFEST by a concrete path (`routes[0].request.schema.query`).
|
|
3
|
+
*
|
|
4
|
+
* The counterpart to `schema-walk.ts`, which navigates a schema: this addresses
|
|
5
|
+
* the author's own document, indices and all. Its own module because three
|
|
6
|
+
* places needed it independently — the CEL scope query resolving a context
|
|
7
|
+
* binding's declaration, the call-site checker resolving an argument map, and
|
|
8
|
+
* the IDE resolving the same map for completion — and three copies of one
|
|
9
|
+
* traversal is exactly what the shared-answer rule exists to prevent.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The value at `path`, or `undefined` when any segment is absent.
|
|
14
|
+
*
|
|
15
|
+
* Absence is what makes a candidate path a CHECK rather than a guess: a caller
|
|
16
|
+
* offering several possible shapes can try each and know a hit is a real node.
|
|
17
|
+
*/
|
|
18
|
+
export function navigateConcretePath(root: Record<string, any>, path: string): unknown {
|
|
19
|
+
let current: unknown = root;
|
|
20
|
+
for (const segment of path.split(".")) {
|
|
21
|
+
if (!segment) continue;
|
|
22
|
+
const match = segment.match(/^([^[]*)((?:\[\d+\])*)$/);
|
|
23
|
+
if (!match) return undefined;
|
|
24
|
+
if (match[1]) {
|
|
25
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
26
|
+
current = (current as Record<string, unknown>)[match[1]];
|
|
27
|
+
}
|
|
28
|
+
for (const index of match[2].matchAll(/\[(\d+)\]/g)) {
|
|
29
|
+
if (!Array.isArray(current)) return undefined;
|
|
30
|
+
current = current[Number(index[1])];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return current;
|
|
34
|
+
}
|
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
|
}
|