@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.
Files changed (64) hide show
  1. package/dist/analysis-registry.d.ts +24 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +35 -0
  4. package/dist/analyzer.d.ts +3 -37
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +76 -470
  7. package/dist/cel-scope-query.d.ts +109 -0
  8. package/dist/cel-scope-query.d.ts.map +1 -0
  9. package/dist/cel-scope-query.js +270 -0
  10. package/dist/cel-scope.d.ts +166 -0
  11. package/dist/cel-scope.d.ts.map +1 -0
  12. package/dist/cel-scope.js +377 -0
  13. package/dist/definition-registry.d.ts +38 -6
  14. package/dist/definition-registry.d.ts.map +1 -1
  15. package/dist/definition-registry.js +66 -22
  16. package/dist/find-manifest.d.ts +10 -0
  17. package/dist/find-manifest.d.ts.map +1 -0
  18. package/dist/find-manifest.js +12 -0
  19. package/dist/index.d.ts +11 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +11 -1
  22. package/dist/invocation-contract.d.ts +11 -0
  23. package/dist/invocation-contract.d.ts.map +1 -1
  24. package/dist/invocation-contract.js +15 -0
  25. package/dist/manifest-analysis.d.ts +73 -0
  26. package/dist/manifest-analysis.d.ts.map +1 -0
  27. package/dist/manifest-analysis.js +78 -0
  28. package/dist/manifest-path.d.ts +18 -0
  29. package/dist/manifest-path.d.ts.map +1 -0
  30. package/dist/manifest-path.js +37 -0
  31. package/dist/schema-compat.d.ts +59 -22
  32. package/dist/schema-compat.d.ts.map +1 -1
  33. package/dist/schema-compat.js +60 -75
  34. package/dist/schema-error-report.d.ts +68 -0
  35. package/dist/schema-error-report.d.ts.map +1 -0
  36. package/dist/schema-error-report.js +356 -0
  37. package/dist/schema-walk.d.ts +25 -0
  38. package/dist/schema-walk.d.ts.map +1 -0
  39. package/dist/schema-walk.js +126 -0
  40. package/dist/telo-version.d.ts +1 -1
  41. package/dist/telo-version.js +1 -1
  42. package/dist/validate-nested-inline.d.ts +22 -1
  43. package/dist/validate-nested-inline.d.ts.map +1 -1
  44. package/dist/validate-nested-inline.js +17 -9
  45. package/dist/validate-step-inputs.d.ts +17 -0
  46. package/dist/validate-step-inputs.d.ts.map +1 -1
  47. package/dist/validate-step-inputs.js +108 -9
  48. package/package.json +2 -2
  49. package/src/analysis-registry.ts +37 -0
  50. package/src/analyzer.ts +83 -587
  51. package/src/cel-scope-query.ts +337 -0
  52. package/src/cel-scope.ts +570 -0
  53. package/src/definition-registry.ts +79 -24
  54. package/src/find-manifest.ts +19 -0
  55. package/src/index.ts +23 -2
  56. package/src/invocation-contract.ts +22 -0
  57. package/src/manifest-analysis.ts +132 -0
  58. package/src/manifest-path.ts +34 -0
  59. package/src/schema-compat.ts +92 -79
  60. package/src/schema-error-report.ts +417 -0
  61. package/src/schema-walk.ts +144 -0
  62. package/src/telo-version.ts +1 -1
  63. package/src/validate-nested-inline.ts +35 -14
  64. 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, formatSingleError, navigateJsonPointer } from "./schema-compat.js";
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
- /** Validates data against a schema using this registry's AJV instance, which has all
166
- * registered definition schemas loaded enabling cross-module $ref resolution.
167
- * A compile failure returns `[]` here; it is surfaced loudly (once, on the
168
- * owning definition) by `schemaCompileError` via the analyzer's
169
- * definition-schema compile check, so resources are never silently skipped. */
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
- let validate: ReturnType<typeof this.ajv.compile>;
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
- private resolveSchemaFromSubMap(
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
- ): ReferenceFieldMap | null {
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 null;
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
- // Static form: absolute path whose anchor is a dotted alias (e.g.
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 null;
389
+ if (!targetKind) return undefined;
344
390
  const targetDef = this.resolve(targetKind);
345
- if (!targetDef?.schema) return null;
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 null;
396
+ if (!subSchema || typeof subSchema !== "object") return undefined;
397
+ return subSchema as Record<string, any>;
398
+ }
351
399
 
352
- return buildFieldMapAtPath(subSchema as Record<string, any>, fieldPath);
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
+ }
@@ -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 function formatSingleError(err: any): string {
259
- const p = err.instancePath || "/";
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 ?? []).map((err: any) => ({
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
- /** Resolve a `$ref` (only `#/$defs/...` form) against the root schema. */
644
- export function resolveRef(schema: Record<string, any>, root: Record<string, any>): Record<string, any> {
645
- if (schema.$ref && typeof schema.$ref === "string" && schema.$ref.startsWith("#/$defs/")) {
646
- const defName = schema.$ref.slice("#/$defs/".length);
647
- const resolved = root.$defs?.[defName];
648
- if (resolved) return resolved;
649
- }
650
- return schema;
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
- export function substituteCelFields(
737
- data: unknown,
738
- schema: Record<string, any>,
739
- rootSchema?: Record<string, any>,
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 root = rootSchema ?? schema;
753
- const resolved = selectUnionBranch(resolveRef(schema, root), data, root);
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 itemSchema = resolveRef((resolved.items ?? {}) as Record<string, any>, root);
793
- return data.map((item, i) =>
794
- substituteCelFields(item, itemSchema, root, onSubstitute, `${path}[${i}]`),
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
  }