@telorun/analyzer 0.1.1

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 (82) hide show
  1. package/LICENSE +17 -0
  2. package/dist/adapters/http-adapter.d.ts +10 -0
  3. package/dist/adapters/http-adapter.d.ts.map +1 -0
  4. package/dist/adapters/http-adapter.js +17 -0
  5. package/dist/adapters/node-adapter.d.ts +15 -0
  6. package/dist/adapters/node-adapter.d.ts.map +1 -0
  7. package/dist/adapters/node-adapter.js +32 -0
  8. package/dist/adapters/registry-adapter.d.ts +11 -0
  9. package/dist/adapters/registry-adapter.d.ts.map +1 -0
  10. package/dist/adapters/registry-adapter.js +33 -0
  11. package/dist/alias-resolver.d.ts +12 -0
  12. package/dist/alias-resolver.d.ts.map +1 -0
  13. package/dist/alias-resolver.js +36 -0
  14. package/dist/analysis-registry.d.ts +29 -0
  15. package/dist/analysis-registry.d.ts.map +1 -0
  16. package/dist/analysis-registry.js +55 -0
  17. package/dist/analyzer.d.ts +14 -0
  18. package/dist/analyzer.d.ts.map +1 -0
  19. package/dist/analyzer.js +314 -0
  20. package/dist/builtins.d.ts +3 -0
  21. package/dist/builtins.d.ts.map +1 -0
  22. package/dist/builtins.js +109 -0
  23. package/dist/cel-environment.d.ts +12 -0
  24. package/dist/cel-environment.d.ts.map +1 -0
  25. package/dist/cel-environment.js +59 -0
  26. package/dist/definition-registry.d.ts +58 -0
  27. package/dist/definition-registry.d.ts.map +1 -0
  28. package/dist/definition-registry.js +155 -0
  29. package/dist/dependency-graph.d.ts +38 -0
  30. package/dist/dependency-graph.d.ts.map +1 -0
  31. package/dist/dependency-graph.js +155 -0
  32. package/dist/index.d.ts +9 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +7 -0
  35. package/dist/manifest-loader.d.ts +11 -0
  36. package/dist/manifest-loader.d.ts.map +1 -0
  37. package/dist/manifest-loader.js +194 -0
  38. package/dist/normalize-inline-resources.d.ts +22 -0
  39. package/dist/normalize-inline-resources.d.ts.map +1 -0
  40. package/dist/normalize-inline-resources.js +136 -0
  41. package/dist/precompile.d.ts +9 -0
  42. package/dist/precompile.d.ts.map +1 -0
  43. package/dist/precompile.js +51 -0
  44. package/dist/reference-field-map.d.ts +53 -0
  45. package/dist/reference-field-map.d.ts.map +1 -0
  46. package/dist/reference-field-map.js +107 -0
  47. package/dist/schema-compat.d.ts +42 -0
  48. package/dist/schema-compat.d.ts.map +1 -0
  49. package/dist/schema-compat.js +234 -0
  50. package/dist/scope-resolver.d.ts +5 -0
  51. package/dist/scope-resolver.d.ts.map +1 -0
  52. package/dist/scope-resolver.js +13 -0
  53. package/dist/types.d.ts +64 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +8 -0
  56. package/dist/validate-cel-context.d.ts +24 -0
  57. package/dist/validate-cel-context.d.ts.map +1 -0
  58. package/dist/validate-cel-context.js +136 -0
  59. package/dist/validate-references.d.ts +19 -0
  60. package/dist/validate-references.d.ts.map +1 -0
  61. package/dist/validate-references.js +275 -0
  62. package/package.json +34 -0
  63. package/src/adapters/http-adapter.ts +23 -0
  64. package/src/adapters/node-adapter.ts +38 -0
  65. package/src/adapters/registry-adapter.ts +43 -0
  66. package/src/alias-resolver.ts +37 -0
  67. package/src/analysis-registry.ts +68 -0
  68. package/src/analyzer.ts +399 -0
  69. package/src/builtins.ts +111 -0
  70. package/src/cel-environment.ts +70 -0
  71. package/src/definition-registry.ts +170 -0
  72. package/src/dependency-graph.ts +187 -0
  73. package/src/index.ts +17 -0
  74. package/src/manifest-loader.ts +203 -0
  75. package/src/normalize-inline-resources.ts +170 -0
  76. package/src/precompile.ts +54 -0
  77. package/src/reference-field-map.ts +147 -0
  78. package/src/schema-compat.ts +264 -0
  79. package/src/scope-resolver.ts +13 -0
  80. package/src/types.ts +68 -0
  81. package/src/validate-cel-context.ts +142 -0
  82. package/src/validate-references.ts +311 -0
@@ -0,0 +1,170 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import { isRefEntry, isScopeEntry, isInlineResource } from "./reference-field-map.js";
3
+ import type { DefinitionRegistry } from "./definition-registry.js";
4
+ import type { AliasResolver } from "./alias-resolver.js";
5
+
6
+ const SYSTEM_KINDS = new Set(["Kernel.Definition", "Kernel.Module", "Kernel.Import"]);
7
+
8
+ /** Replaces characters outside [a-zA-Z0-9_] with underscores. */
9
+ function sanitizeName(raw: string): string {
10
+ return raw.replace(/[^a-zA-Z0-9_]/g, "_");
11
+ }
12
+
13
+ /**
14
+ * Phase 2 — Inline resource normalization.
15
+ *
16
+ * After all manifests and definitions are loaded, walks every non-system resource's
17
+ * x-telo-ref slots. For each inline resource value (has keys beyond kind/name/metadata),
18
+ * assigns a deterministic name, extracts it as a first-class manifest, and replaces
19
+ * the inline value in-place with `{kind, name}`. Newly extracted resources are enqueued
20
+ * so nested inlines are resolved in the same pass.
21
+ *
22
+ * Naming scheme:
23
+ * {parentName}_{pathSegment}[_{itemName|index}]_{fieldName}
24
+ * e.g. TestBasicAddition_steps_AddTwoNumbers_invoke
25
+ * TestBasicAddition_steps_0_invoke (when step has no name)
26
+ *
27
+ * Returns a new array containing the original manifests (mutated in-place) plus all
28
+ * extracted manifests. The original array is not mutated.
29
+ */
30
+ export function normalizeInlineResources(
31
+ resources: ResourceManifest[],
32
+ registry: DefinitionRegistry,
33
+ aliases?: AliasResolver,
34
+ ): ResourceManifest[] {
35
+ const result = [...resources];
36
+
37
+ // Queue: all non-system resources with a name. Extracted resources are appended.
38
+ const queue = resources.filter(
39
+ (r): r is ResourceManifest & { metadata: { name: string } } =>
40
+ typeof r.metadata?.name === "string" && !!r.kind && !SYSTEM_KINDS.has(r.kind),
41
+ );
42
+
43
+ let i = 0;
44
+ while (i < queue.length) {
45
+ const resource = queue[i++];
46
+ const fieldMap = registry.getFieldMapForKind(resource.kind, aliases);
47
+ if (!fieldMap) continue;
48
+
49
+ const parentName = resource.metadata.name as string;
50
+ const parentModule = resource.metadata.module as string | undefined;
51
+
52
+ // Collect scope visibility prefixes so we can route extracted resources correctly.
53
+ const scopePrefixes: string[] = [];
54
+ for (const [, entry] of fieldMap) {
55
+ if (!isScopeEntry(entry)) continue;
56
+ const paths = Array.isArray(entry.scope) ? entry.scope : [entry.scope];
57
+ for (const p of paths) {
58
+ scopePrefixes.push(p.replace(/^\//, "").replace(/\//g, "."));
59
+ }
60
+ }
61
+
62
+ for (const [fieldPath, entry] of fieldMap) {
63
+ if (!isRefEntry(entry)) continue;
64
+
65
+ const inScope = scopePrefixes.some(
66
+ (prefix) =>
67
+ fieldPath === prefix ||
68
+ fieldPath.startsWith(prefix + ".") ||
69
+ fieldPath.startsWith(prefix + "["),
70
+ );
71
+
72
+ const invocationContext = isRefEntry(entry) ? entry.context : undefined;
73
+ const extracted = extractInlinesAtPath(resource, fieldPath, parentName, parentModule, invocationContext);
74
+ for (const manifest of extracted) {
75
+ result.push(manifest);
76
+ queue.push(manifest as ResourceManifest & { metadata: { name: string } });
77
+ // TODO Phase 5: when inScope, add to parent's scope array instead of outer set
78
+ void inScope;
79
+ }
80
+ }
81
+ }
82
+
83
+ return result;
84
+ }
85
+
86
+ /**
87
+ * Walks `resource` following `fieldPath` (dot notation, `[]` = array traversal).
88
+ * Mutates the resource in-place: replaces each inline value with `{kind, name}`.
89
+ * Returns the extracted manifests.
90
+ */
91
+ function extractInlinesAtPath(
92
+ resource: ResourceManifest,
93
+ fieldPath: string,
94
+ parentName: string,
95
+ parentModule: string | undefined,
96
+ invocationContext?: Record<string, any>,
97
+ ): ResourceManifest[] {
98
+ const extracted: ResourceManifest[] = [];
99
+ const parts = fieldPath.split(".");
100
+
101
+ function traverse(obj: unknown, partsLeft: string[], nameParts: string[]): void {
102
+ if (!obj || typeof obj !== "object" || partsLeft.length === 0) return;
103
+
104
+ const [head, ...rest] = partsLeft;
105
+ const isArr = head.endsWith("[]");
106
+ const key = isArr ? head.slice(0, -2) : head;
107
+ const container = obj as Record<string, unknown>;
108
+ const val = container[key];
109
+ if (val == null) return;
110
+
111
+ if (isArr) {
112
+ if (!Array.isArray(val)) return;
113
+ for (let idx = 0; idx < val.length; idx++) {
114
+ const elem = val[idx];
115
+ if (!elem || typeof elem !== "object") continue;
116
+ const elemId =
117
+ typeof (elem as Record<string, unknown>).name === "string"
118
+ ? ((elem as Record<string, unknown>).name as string)
119
+ : String(idx);
120
+
121
+ if (rest.length === 0) {
122
+ // Array element itself is the ref slot
123
+ if (isInlineResource(elem as Record<string, unknown>)) {
124
+ const name = sanitizeName([parentName, ...nameParts, key, elemId].join("_"));
125
+ extracted.push(buildManifest(elem as Record<string, unknown>, name, parentModule, invocationContext));
126
+ val[idx] = { kind: (elem as Record<string, unknown>).kind, name };
127
+ }
128
+ } else {
129
+ traverse(elem, rest, [...nameParts, key, elemId]);
130
+ }
131
+ }
132
+ } else {
133
+ if (rest.length === 0) {
134
+ // val is the ref slot
135
+ if (val && typeof val === "object" && !Array.isArray(val) && isInlineResource(val as Record<string, unknown>)) {
136
+ const name = sanitizeName([parentName, ...nameParts, key].join("_"));
137
+ extracted.push(buildManifest(val as Record<string, unknown>, name, parentModule, invocationContext));
138
+ container[key] = { kind: (val as Record<string, unknown>).kind, name };
139
+ }
140
+ } else {
141
+ traverse(val, rest, [...nameParts, key]);
142
+ }
143
+ }
144
+ }
145
+
146
+ traverse(resource, parts, []);
147
+ return extracted;
148
+ }
149
+
150
+ function buildManifest(
151
+ inline: Record<string, unknown>,
152
+ name: string,
153
+ parentModule: string | undefined,
154
+ invocationContext?: Record<string, any>,
155
+ ): ResourceManifest {
156
+ const existingMeta =
157
+ inline.metadata && typeof inline.metadata === "object"
158
+ ? (inline.metadata as Record<string, unknown>)
159
+ : {};
160
+ return {
161
+ ...inline,
162
+ metadata: {
163
+ ...existingMeta,
164
+ name,
165
+ // Inherit parent module only if the inline doesn't already declare one
166
+ ...(parentModule && !existingMeta.module ? { module: parentModule } : {}),
167
+ ...(invocationContext ? { xTeloInvocationContext: invocationContext } : {}),
168
+ },
169
+ } as ResourceManifest;
170
+ }
@@ -0,0 +1,54 @@
1
+ import type { CompiledValue } from "@telorun/sdk";
2
+ import { celEnvironment } from "./cel-environment.js";
3
+
4
+ const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
5
+ const EXACT_TEMPLATE_REGEX = /^\s*\$\{\{\s*([^}]+?)\s*\}\}\s*$/;
6
+
7
+ /**
8
+ * Walks a raw YAML document and replaces all "${{ expr }}" strings with
9
+ * CompiledValue wrappers. Throws on CEL syntax errors.
10
+ * Intended to be called once per document at load time.
11
+ * Kernel.Definition documents are returned unchanged — their schema fields
12
+ * are static metadata and must not be treated as CEL templates.
13
+ */
14
+ export function precompileDoc(doc: unknown): unknown {
15
+ if (typeof doc === "string") return compileString(doc);
16
+ if (Array.isArray(doc)) return doc.map(precompileDoc);
17
+ // Only recurse into plain objects. Class instances (ResourceInstance, ScopeHandle, etc.)
18
+ // are returned as-is — their prototype methods must not be lost by object reconstruction.
19
+ if (doc !== null && typeof doc === "object" && Object.getPrototypeOf(doc) === Object.prototype) {
20
+ const result: Record<string, unknown> = {};
21
+ for (const [k, v] of Object.entries(doc as Record<string, unknown>)) {
22
+ result[k] = precompileDoc(v);
23
+ }
24
+ return result;
25
+ }
26
+ return doc;
27
+ }
28
+
29
+ function compileString(s: string): unknown {
30
+ if (!s.includes("${{")) return s;
31
+
32
+ const exact = s.match(EXACT_TEMPLATE_REGEX);
33
+ if (exact) {
34
+ const fn = celEnvironment.parse(exact[1].trim());
35
+ return { __compiled: true, call: (ctx: Record<string, unknown>) => fn(ctx) } satisfies CompiledValue;
36
+ }
37
+
38
+ // Interpolated template — collect literal parts + compiled sub-expressions
39
+ const parts: Array<string | CompiledValue> = [];
40
+ let last = 0;
41
+ for (const m of s.matchAll(TEMPLATE_REGEX)) {
42
+ if (m.index! > last) parts.push(s.slice(last, m.index));
43
+ const fn = celEnvironment.parse(m[1].trim());
44
+ parts.push({ __compiled: true, call: (ctx: Record<string, unknown>) => fn(ctx) } satisfies CompiledValue);
45
+ last = m.index! + m[0].length;
46
+ }
47
+ if (last < s.length) parts.push(s.slice(last));
48
+
49
+ return {
50
+ __compiled: true,
51
+ call: (ctx: Record<string, unknown>) =>
52
+ parts.map((p) => (typeof p === "string" ? p : String(p.call(ctx) ?? ""))).join(""),
53
+ } satisfies CompiledValue;
54
+ }
@@ -0,0 +1,147 @@
1
+ /** An entry for a field that carries one or more x-telo-ref constraints. */
2
+ export interface RefFieldEntry {
3
+ /** One or more canonical ref strings ("namespace/module#TypeName" or "kernel#TypeName").
4
+ * Multiple entries arise from anyOf branches. */
5
+ refs: string[];
6
+ /** True when the field path traversed through at least one array (path contains "[]"). */
7
+ isArray: boolean;
8
+ /** x-telo-context schema declared on this ref slot, if any. Describes the CEL invocation
9
+ * context available to resources placed in this slot. */
10
+ context?: Record<string, any>;
11
+ }
12
+
13
+ /** An entry for a field that declares an execution scope (x-telo-scope). */
14
+ export interface ScopeFieldEntry {
15
+ /** JSON Pointer(s) (RFC 6901) declaring where x-telo-ref slots within this field can
16
+ * resolve to the scoped resources. */
17
+ scope: string | string[];
18
+ }
19
+
20
+ /** An entry for a field whose schema is resolved dynamically from a referenced resource's
21
+ * definition schema (x-telo-schema-from). */
22
+ export interface SchemaFromFieldEntry {
23
+ /** Full path expression as written in the schema, e.g.:
24
+ * - "backend/$defs/NodeOptions" (relative: sibling x-telo-ref property)
25
+ * - "/backend/$defs/NodeOptions" (absolute: root-level x-telo-ref property) */
26
+ schemaFrom: string;
27
+ }
28
+
29
+ export type FieldMapEntry = RefFieldEntry | ScopeFieldEntry | SchemaFromFieldEntry;
30
+
31
+ /** Map from field path to its reference or scope metadata.
32
+ * Paths use dot notation; array traversal is denoted by `[]` (e.g. "steps[].invoke"). */
33
+ export type ReferenceFieldMap = Map<string, FieldMapEntry>;
34
+
35
+ export function isRefEntry(entry: FieldMapEntry): entry is RefFieldEntry {
36
+ return "refs" in entry;
37
+ }
38
+
39
+ export function isScopeEntry(entry: FieldMapEntry): entry is ScopeFieldEntry {
40
+ return "scope" in entry;
41
+ }
42
+
43
+ export function isSchemaFromEntry(entry: FieldMapEntry): entry is SchemaFromFieldEntry {
44
+ return "schemaFrom" in entry;
45
+ }
46
+
47
+ /** Keys that a named reference object may have. Values beyond these indicate an inline resource. */
48
+ export const REFERENCE_KEYS = new Set(["kind", "name", "metadata"]);
49
+
50
+ /** True when `val` is an inline resource definition rather than a named reference.
51
+ * A named reference (has string `name`) may carry extra keys (e.g. `inputs`) that
52
+ * are runtime call parameters — those are never inline resources. */
53
+ export function isInlineResource(val: Record<string, unknown>): boolean {
54
+ if (typeof val.name === "string") return false;
55
+ return Object.keys(val).some((k) => !REFERENCE_KEYS.has(k));
56
+ }
57
+
58
+ /** Resolves all values at a field map path in a resource config.
59
+ * `[]` in a path segment means "iterate array at this key". */
60
+ export function resolveFieldValues(obj: unknown, path: string): unknown[] {
61
+ const parts = path.split(".");
62
+ let current: unknown[] = [obj];
63
+ for (const part of parts) {
64
+ const isArray = part.endsWith("[]");
65
+ const key = isArray ? part.slice(0, -2) : part;
66
+ const next: unknown[] = [];
67
+ for (const item of current) {
68
+ if (!item || typeof item !== "object") continue;
69
+ const val = (item as Record<string, unknown>)[key];
70
+ if (val == null) continue;
71
+ if (isArray && Array.isArray(val)) next.push(...val);
72
+ else if (!isArray) next.push(val);
73
+ }
74
+ current = next;
75
+ }
76
+ return current;
77
+ }
78
+
79
+ /**
80
+ * Traverses a definition's JSON Schema once and returns a field map recording every
81
+ * x-telo-ref slot and every x-telo-scope slot.
82
+ *
83
+ * - A node with `x-telo-ref` → RefFieldEntry with refs: [that value]
84
+ * - A node with `anyOf` whose branches have `x-telo-ref` → RefFieldEntry with all branch refs
85
+ * - A node with `x-telo-scope` → ScopeFieldEntry
86
+ * - A node with `type: array` + `items` → recurse into items with path "fieldName[]"
87
+ * - A node with `properties` → recurse into each property
88
+ */
89
+ export function buildReferenceFieldMap(schema: Record<string, any>): ReferenceFieldMap {
90
+ const map: ReferenceFieldMap = new Map();
91
+ if (schema.properties) {
92
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
93
+ traverseNode(propSchema as Record<string, any>, key, map);
94
+ }
95
+ }
96
+ return map;
97
+ }
98
+
99
+ function collectRefs(node: Record<string, any>): string[] {
100
+ const refs: string[] = [];
101
+ if (typeof node["x-telo-ref"] === "string") {
102
+ refs.push(node["x-telo-ref"]);
103
+ }
104
+ if (Array.isArray(node.anyOf)) {
105
+ for (const branch of node.anyOf) {
106
+ if (branch && typeof branch["x-telo-ref"] === "string") {
107
+ refs.push(branch["x-telo-ref"]);
108
+ }
109
+ }
110
+ }
111
+ return refs;
112
+ }
113
+
114
+ function traverseNode(node: Record<string, any>, path: string, map: ReferenceFieldMap): void {
115
+ // Scope slot — record and stop; do not recurse into scope contents
116
+ if ("x-telo-scope" in node) {
117
+ map.set(path, { scope: node["x-telo-scope"] });
118
+ return;
119
+ }
120
+
121
+ // Schema-from slot — record and stop; no further traversal needed
122
+ if ("x-telo-schema-from" in node) {
123
+ map.set(path, { schemaFrom: node["x-telo-schema-from"] });
124
+ return;
125
+ }
126
+
127
+ // Reference slot (direct or via anyOf)
128
+ const refs = collectRefs(node);
129
+ if (refs.length > 0) {
130
+ const entry: RefFieldEntry = { refs, isArray: path.includes("[]") };
131
+ if (node["x-telo-context"]) entry.context = node["x-telo-context"] as Record<string, any>;
132
+ map.set(path, entry);
133
+ return;
134
+ }
135
+
136
+ // Array — recurse into items
137
+ if (node.type === "array" && node.items) {
138
+ traverseNode(node.items as Record<string, any>, path + "[]", map);
139
+ }
140
+
141
+ // Object — recurse into properties
142
+ if (node.properties) {
143
+ for (const [key, propSchema] of Object.entries(node.properties)) {
144
+ traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map);
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,264 @@
1
+ import AjvModule from "ajv";
2
+ import addFormats from "ajv-formats";
3
+
4
+ const Ajv = (AjvModule as any).default ?? AjvModule;
5
+
6
+ /** Creates a configured AJV instance (allErrors, strict: false, with formats).
7
+ * Called once for the module-level instance and once per DefinitionRegistry instance. */
8
+ export function createAjv(): InstanceType<typeof Ajv> {
9
+ const instance = new Ajv({ allErrors: true, strict: false });
10
+ (addFormats as any).default
11
+ ? (addFormats as any).default(instance)
12
+ : (addFormats as any)(instance);
13
+ return instance;
14
+ }
15
+
16
+ const ajv = createAjv();
17
+
18
+ export interface CompatibilityResult {
19
+ compatible: boolean;
20
+ issues: string[];
21
+ }
22
+
23
+ /** Conservative structural JSON Schema compatibility check.
24
+ * Only flags definite mismatches: missing required fields and primitive type conflicts.
25
+ * Ambiguous cases (anyOf/oneOf/etc.) are treated as compatible. */
26
+ export function checkSchemaCompatibility(
27
+ source: Record<string, any>,
28
+ target: Record<string, any>,
29
+ ): CompatibilityResult {
30
+ const issues: string[] = [];
31
+ checkObject(source, target, "", issues);
32
+ return { compatible: issues.length === 0, issues };
33
+ }
34
+
35
+ function checkObject(
36
+ source: Record<string, any>,
37
+ target: Record<string, any>,
38
+ path: string,
39
+ issues: string[],
40
+ ): void {
41
+ const targetRequired: string[] = target.required ?? [];
42
+ const sourceProps: Record<string, any> = source.properties ?? {};
43
+ const targetProps: Record<string, any> = target.properties ?? {};
44
+
45
+ for (const field of targetRequired) {
46
+ if (!(field in sourceProps)) {
47
+ issues.push(`${path}/${field}: required by target but missing from source`);
48
+ continue;
49
+ }
50
+ const srcProp = sourceProps[field];
51
+ const tgtProp = targetProps[field];
52
+ if (tgtProp && srcProp) {
53
+ checkProperty(srcProp, tgtProp, `${path}/${field}`, issues);
54
+ }
55
+ }
56
+ }
57
+
58
+ function checkProperty(
59
+ source: Record<string, any>,
60
+ target: Record<string, any>,
61
+ path: string,
62
+ issues: string[],
63
+ ): void {
64
+ // Only flag definite primitive type clashes; skip anyOf/oneOf/allOf
65
+ if (
66
+ source.type &&
67
+ target.type &&
68
+ typeof source.type === "string" &&
69
+ typeof target.type === "string" &&
70
+ source.type !== target.type
71
+ ) {
72
+ issues.push(
73
+ `${path}: type mismatch — source is '${source.type}', target expects '${target.type}'`,
74
+ );
75
+ return;
76
+ }
77
+ if (target.type === "object" && source.type === "object") {
78
+ checkObject(source, target, path, issues);
79
+ }
80
+ }
81
+
82
+ export function formatSingleError(err: any): string {
83
+ const p = err.instancePath || "/";
84
+ return `${p} ${err.message ?? "is invalid"}`;
85
+ }
86
+
87
+ export function formatAjvErrors(errors: any[] | null | undefined): string {
88
+ if (!errors || errors.length === 0) return "Unknown schema error";
89
+ return errors.map(formatSingleError).join("; ");
90
+ }
91
+
92
+ /** Converts an AJV error object to a dotted path string compatible with PositionIndex keys.
93
+ * e.g. instancePath "/config/routes/0/handler" → "config.routes[0].handler"
94
+ * For "required" keyword errors, appends the missing property to the parent path. */
95
+ function ajvErrorToPath(err: any): string {
96
+ const instancePath = (err.instancePath ?? "") as string;
97
+ const parts = instancePath.split("/").filter((p) => p !== "");
98
+ let result = "";
99
+ for (const part of parts) {
100
+ if (/^\d+$/.test(part)) {
101
+ result += `[${part}]`;
102
+ } else {
103
+ result += result ? `.${part}` : part;
104
+ }
105
+ }
106
+ if (err.keyword === "required" && err.params?.missingProperty) {
107
+ const missing = err.params.missingProperty as string;
108
+ result += result ? `.${missing}` : missing;
109
+ }
110
+ return result;
111
+ }
112
+
113
+ /** A schema validation issue with a dotted-path pointer to the offending field. */
114
+ export interface SchemaIssue {
115
+ message: string;
116
+ /** Dotted path to the field (e.g. "config.handler"). Empty string means root. */
117
+ path: string;
118
+ }
119
+
120
+ /** Validate actual data against a JSON Schema. Returns issues with path info, or empty array if valid. */
121
+ export function validateAgainstSchema(data: unknown, schema: Record<string, any>): SchemaIssue[] {
122
+ let validate: ReturnType<typeof ajv.compile>;
123
+ try {
124
+ validate = ajv.compile(schema);
125
+ } catch {
126
+ return [];
127
+ }
128
+ if (validate(data)) return [];
129
+ return (validate.errors ?? []).map((err: any) => ({
130
+ message: formatSingleError(err),
131
+ path: ajvErrorToPath(err),
132
+ }));
133
+ }
134
+
135
+ /** Resolves a JSON Pointer (RFC 6901, must start with "/") into a schema object.
136
+ * Returns undefined when any segment along the path is missing or not an object. */
137
+ export function navigateJsonPointer(schema: unknown, pointer: string): unknown {
138
+ const segments = pointer.split("/").slice(1); // drop leading empty string from "/"
139
+ let current: unknown = schema;
140
+ for (const seg of segments) {
141
+ if (current === null || typeof current !== "object") return undefined;
142
+ current = (current as Record<string, unknown>)[seg];
143
+ }
144
+ return current;
145
+ }
146
+
147
+ /** Navigate a JSON Schema following a `walkCelExpressions`-style path
148
+ * (e.g. `port`, `routes[0].handler.when`).
149
+ * Dot-separated segments navigate `properties`; `[N]` indices navigate `items`.
150
+ * Stops and returns the current node when a union type (`anyOf`/`oneOf`) is reached.
151
+ * Returns `undefined` if any segment cannot be resolved. */
152
+ export function navigateSchemaToExprPath(
153
+ schema: Record<string, any>,
154
+ path: string,
155
+ ): Record<string, any> | undefined {
156
+ if (!path) return schema;
157
+ let current: Record<string, any> = schema;
158
+ for (const part of path.split(".")) {
159
+ if (!current || typeof current !== "object") return undefined;
160
+ if (current.anyOf || current.oneOf) return current;
161
+ const m = part.match(/^([a-zA-Z_][a-zA-Z0-9_]*)((?:\[\d+\])*)$/);
162
+ if (!m) return undefined;
163
+ const [, ident, indices] = m as [string, string, string];
164
+ const props = current.properties as Record<string, any> | undefined;
165
+ if (!props || !(ident in props)) return undefined;
166
+ current = props[ident] as Record<string, any>;
167
+ if (!current) return undefined;
168
+ const indexCount = (indices.match(/\[/g) ?? []).length;
169
+ for (let i = 0; i < indexCount; i++) {
170
+ if (!current || typeof current !== "object") return undefined;
171
+ if (current.anyOf || current.oneOf) return current;
172
+ if (!current.items) return undefined;
173
+ current = current.items as Record<string, any>;
174
+ }
175
+ }
176
+ return current;
177
+ }
178
+
179
+ /** Map a JSON Schema type annotation to a CEL type string. */
180
+ export function jsonSchemaToCelType(schema: Record<string, any> | undefined): string {
181
+ if (!schema || typeof schema !== "object") return "dyn";
182
+ if (schema.anyOf || schema.oneOf || schema.allOf) return "dyn";
183
+ if (Array.isArray(schema.type)) return "dyn";
184
+ switch (schema.type) {
185
+ case "integer": return "int";
186
+ case "number": return "double";
187
+ case "string": return "string";
188
+ case "boolean": return "bool";
189
+ case "array": return "list";
190
+ case "object": return "map";
191
+ case "null": return "null_type";
192
+ }
193
+ if (schema.properties) return "map";
194
+ if (schema.items) return "list";
195
+ return "dyn";
196
+ }
197
+
198
+ /** Check whether a CEL return type is compatible with a JSON Schema type constraint. */
199
+ export function celTypeSatisfiesJsonSchema(
200
+ celType: string,
201
+ schema: Record<string, any>,
202
+ ): boolean {
203
+ if (celType === "dyn") return true;
204
+ if (!schema.type && !schema.anyOf && !schema.oneOf && !schema.allOf) return true;
205
+ if (schema.anyOf || schema.oneOf || schema.allOf) return true;
206
+ const schemaTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
207
+ const accepted: Record<string, string[]> = {
208
+ int: ["integer", "number"],
209
+ uint: ["integer", "number"],
210
+ double: ["number"],
211
+ string: ["string"],
212
+ bool: ["boolean"],
213
+ list: ["array"],
214
+ map: ["object"],
215
+ null_type: ["null"],
216
+ timestamp: ["string"],
217
+ duration: ["string"],
218
+ bytes: ["string"],
219
+ };
220
+ const compatibleWith = accepted[celType];
221
+ if (!compatibleWith) return true; // unknown CEL type — don't flag
222
+ return compatibleWith.some((t) => schemaTypes.includes(t));
223
+ }
224
+
225
+ /** Return a literal placeholder value of the correct schema type for AJV. */
226
+ export function celPlaceholderForSchema(schema: Record<string, any>): unknown {
227
+ if (schema.default !== undefined) return schema.default;
228
+ switch (schema.type) {
229
+ case "integer":
230
+ case "number": return schema.minimum ?? 0;
231
+ case "string": return "";
232
+ case "boolean": return false;
233
+ case "array": return [];
234
+ case "object": return {};
235
+ default: return null;
236
+ }
237
+ }
238
+
239
+ const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
240
+
241
+ /** Deep-clone `data`, replacing every pure CEL template string (`${{ expr }}`) with a
242
+ * schema-appropriate placeholder so AJV can validate non-CEL fields without false positives. */
243
+ export function substituteCelFields(data: unknown, schema: Record<string, any>): unknown {
244
+ if (typeof data === "string" && CEL_PURE_RE.test(data)) {
245
+ return celPlaceholderForSchema(schema);
246
+ }
247
+ if (Array.isArray(data)) {
248
+ const itemSchema = (schema.items ?? {}) as Record<string, any>;
249
+ return data.map((item) => substituteCelFields(item, itemSchema));
250
+ }
251
+ if (data !== null && typeof data === "object") {
252
+ const props = (schema.properties ?? {}) as Record<string, any>;
253
+ const addlProps =
254
+ schema.additionalProperties && typeof schema.additionalProperties === "object"
255
+ ? (schema.additionalProperties as Record<string, any>)
256
+ : undefined;
257
+ const result: Record<string, unknown> = {};
258
+ for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
259
+ result[k] = substituteCelFields(v, (props[k] ?? addlProps ?? {}) as Record<string, any>);
260
+ }
261
+ return result;
262
+ }
263
+ return data;
264
+ }
@@ -0,0 +1,13 @@
1
+ import { JSONPath } from "jsonpath-plus";
2
+
3
+ /** Evaluate a JSON Path (RFC 9535) expression against a resource config and return the
4
+ * string values found. These are the referenced resource names at that call site.
5
+ * Returns [] when the path matches nothing or yields non-string values. Never throws. */
6
+ export function resolveScope(config: Record<string, any>, scope: string): string[] {
7
+ try {
8
+ const results: unknown[] = JSONPath({ path: scope, json: config, resultType: "value" });
9
+ return results.filter((v): v is string => typeof v === "string");
10
+ } catch {
11
+ return [];
12
+ }
13
+ }