@telorun/analyzer 0.11.0 → 1.1.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 (48) hide show
  1. package/README.md +3 -3
  2. package/dist/analysis-registry.d.ts +7 -0
  3. package/dist/analysis-registry.d.ts.map +1 -1
  4. package/dist/analysis-registry.js +38 -0
  5. package/dist/analyzer.d.ts.map +1 -1
  6. package/dist/analyzer.js +44 -9
  7. package/dist/builtins.d.ts.map +1 -1
  8. package/dist/builtins.js +44 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -0
  12. package/dist/kernel-globals.d.ts.map +1 -1
  13. package/dist/kernel-globals.js +9 -11
  14. package/dist/normalize-inline-resources.d.ts.map +1 -1
  15. package/dist/normalize-inline-resources.js +26 -14
  16. package/dist/position-metadata.d.ts +5 -1
  17. package/dist/position-metadata.d.ts.map +1 -1
  18. package/dist/position-metadata.js +8 -1
  19. package/dist/reference-field-map.d.ts +21 -4
  20. package/dist/reference-field-map.d.ts.map +1 -1
  21. package/dist/reference-field-map.js +35 -19
  22. package/dist/residual-schema.d.ts +23 -0
  23. package/dist/residual-schema.d.ts.map +1 -0
  24. package/dist/residual-schema.js +45 -0
  25. package/dist/rewrite-synthetic-origins.d.ts +10 -0
  26. package/dist/rewrite-synthetic-origins.d.ts.map +1 -0
  27. package/dist/rewrite-synthetic-origins.js +55 -0
  28. package/dist/validate-cel-context.d.ts +5 -0
  29. package/dist/validate-cel-context.d.ts.map +1 -1
  30. package/dist/validate-cel-context.js +27 -15
  31. package/dist/validate-provider-coherence.d.ts +23 -0
  32. package/dist/validate-provider-coherence.d.ts.map +1 -0
  33. package/dist/validate-provider-coherence.js +148 -0
  34. package/dist/validate-references.js +24 -24
  35. package/package.json +5 -3
  36. package/src/analysis-registry.ts +37 -0
  37. package/src/analyzer.ts +45 -11
  38. package/src/builtins.ts +44 -1
  39. package/src/index.ts +1 -0
  40. package/src/kernel-globals.ts +9 -11
  41. package/src/normalize-inline-resources.ts +48 -13
  42. package/src/position-metadata.ts +8 -1
  43. package/src/reference-field-map.ts +46 -18
  44. package/src/residual-schema.ts +49 -0
  45. package/src/rewrite-synthetic-origins.ts +75 -0
  46. package/src/validate-cel-context.ts +28 -15
  47. package/src/validate-provider-coherence.ts +166 -0
  48. package/src/validate-references.ts +24 -24
package/src/analyzer.ts CHANGED
@@ -14,6 +14,7 @@ import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kerne
14
14
  import { computeSuggestKind } from "./kind-suggest.js";
15
15
  import { isModuleKind } from "./module-kinds.js";
16
16
  import { normalizeInlineResources } from "./normalize-inline-resources.js";
17
+ import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
17
18
  import {
18
19
  celTypeSatisfiesJsonSchema,
19
20
  substituteCelFields,
@@ -28,6 +29,7 @@ import {
28
29
  resolveTypeFieldToSchema,
29
30
  } from "./validate-cel-context.js";
30
31
  import { validateExtends } from "./validate-extends.js";
32
+ import { validateProviderCoherence } from "./validate-provider-coherence.js";
31
33
  import { validateReferences } from "./validate-references.js";
32
34
  import { validateThrowsCoverage } from "./validate-throws-coverage.js";
33
35
 
@@ -629,6 +631,35 @@ export class StaticAnalyzer {
629
631
  }
630
632
  }
631
633
 
634
+ // Library env: rejection — `env:` on a Library `variables` / `secrets`
635
+ // entry is forbidden. The Library entry schema is otherwise open so that
636
+ // any JSON Schema property schema is valid; this targeted check produces
637
+ // a clear diagnostic instead of a generic "additional property" error.
638
+ for (const m of allManifests) {
639
+ if (m.kind !== "Telo.Library") continue;
640
+ const filePath = (m.metadata as { source?: string } | undefined)?.source;
641
+ const moduleName = m.metadata?.name as string | undefined;
642
+ const resource = moduleName ? { kind: m.kind, name: moduleName } : undefined;
643
+ for (const block of ["variables", "secrets"] as const) {
644
+ const entries = (m as Record<string, any>)[block];
645
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) continue;
646
+ for (const [entryName, entry] of Object.entries(entries)) {
647
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
648
+ if ("env" in (entry as Record<string, unknown>)) {
649
+ diagnostics.push({
650
+ severity: DiagnosticSeverity.Error,
651
+ code: "LIBRARY_ENV_KEY_REJECTED",
652
+ source: SOURCE,
653
+ message:
654
+ `Telo.Library ${block}/${entryName}: 'env:' is only permitted on Telo.Application entries. ` +
655
+ `Libraries must receive values from importers via the parent manifest's variables / secrets block.`,
656
+ data: { resource, filePath, path: `${block}.${entryName}.env` },
657
+ });
658
+ }
659
+ }
660
+ }
661
+ }
662
+
632
663
  // Build typed kernel globals schema so x-telo-context chain validation
633
664
  // recognises variables, secrets, resources, env automatically
634
665
  const kernelGlobals = buildKernelGlobalsSchema(allManifests);
@@ -776,17 +807,15 @@ export class StaticAnalyzer {
776
807
  }
777
808
  }
778
809
 
779
- // Top-level `result:` (a sibling, only meaningful with `provide:`) is a
780
- // post-call mapping that must satisfy the abstract this definition
781
- // `extends` (`outputType`). The target's outputType lives on `provide.kind`
810
+ // Top-level `result:` is a post-call mapping that must satisfy the abstract
811
+ // this definition `extends` (`outputType`). It's a sibling of whichever
812
+ // dispatch entry-point declared a kind-typed target (`provide:` or
813
+ // `invoke:`). The target's outputType lives on the dispatcher's `kind`
782
814
  // and is what `result` is typed against *inside* CEL — separate role.
783
- if (
784
- provide &&
785
- typeof provide === "object" &&
786
- !Array.isArray(provide) &&
787
- md.result &&
788
- typeof md.result === "object"
789
- ) {
815
+ const hasDispatchObject =
816
+ (provide && typeof provide === "object" && !Array.isArray(provide)) ||
817
+ (invoke && typeof invoke === "object" && !Array.isArray(invoke));
818
+ if (hasDispatchObject && md.result && typeof md.result === "object") {
790
819
  const extendsValue = md.extends as string | undefined;
791
820
  if (typeof extendsValue === "string" && extendsValue.length > 0) {
792
821
  const abstractSchema = lookupDefinitionTypeField(
@@ -920,10 +949,15 @@ export class StaticAnalyzer {
920
949
  // Validate `extends` fields and flag legacy `capability: <UserAbstract>` overload.
921
950
  diagnostics.push(...validateExtends(allManifests, defs, aliases));
922
951
 
952
+ // Validate provider coherence rules for `provide:` template-target definitions.
953
+ diagnostics.push(...validateProviderCoherence(allManifests, defs, aliases));
954
+
923
955
  // Validate throws: declarations and catches: coverage (rules 1, 2, 4, 7)
924
956
  diagnostics.push(...validateThrowsCoverage(allManifests, defs, aliases, this.celEnv));
925
957
 
926
- return diagnostics;
958
+ // Reroute diagnostics on synthetic (inline-extracted) resources back to
959
+ // the chain root so position-index lookups land on the parent doc.
960
+ return rewriteSyntheticOrigins(diagnostics, allManifests);
927
961
  }
928
962
 
929
963
  analyzeErrors(
package/src/builtins.ts CHANGED
@@ -149,7 +149,12 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
149
149
  additionalProperties: false,
150
150
  properties: {
151
151
  self: { "x-telo-context-from-root": "schema" },
152
- result: { "x-telo-context-from-ref-kind": "provide/kind#outputType" },
152
+ result: {
153
+ "x-telo-context-from-ref-kind": [
154
+ "provide/kind#outputType",
155
+ "invoke/kind#outputType",
156
+ ],
157
+ },
153
158
  },
154
159
  },
155
160
  },
@@ -222,6 +227,44 @@ export const KERNEL_BUILTINS: ResourceDefinition[] = [
222
227
  type: "array",
223
228
  items: { type: "string" },
224
229
  },
230
+ // Application-level environment contract. Each entry layers `env:`
231
+ // (required, names the source env var) and `default:` (optional, used
232
+ // when the env var is unset) on top of an open JSON Schema property
233
+ // schema. `type:` constrains the coercion rule applied to the raw env
234
+ // string (scalars per-type; `object` / `array` via JSON.parse with the
235
+ // matching top-level type). All other JSON Schema keywords are passed
236
+ // through unchanged and applied to the coerced value via the standard
237
+ // schema validator. See kernel/nodejs/src/application-env.ts.
238
+ variables: {
239
+ type: "object",
240
+ additionalProperties: {
241
+ type: "object",
242
+ required: ["env", "type"],
243
+ properties: {
244
+ env: { type: "string" },
245
+ type: {
246
+ type: "string",
247
+ enum: ["string", "integer", "number", "boolean", "object", "array"],
248
+ },
249
+ default: {},
250
+ },
251
+ },
252
+ },
253
+ secrets: {
254
+ type: "object",
255
+ additionalProperties: {
256
+ type: "object",
257
+ required: ["env", "type"],
258
+ properties: {
259
+ env: { type: "string" },
260
+ type: {
261
+ type: "string",
262
+ enum: ["string", "integer", "number", "boolean", "object", "array"],
263
+ },
264
+ default: {},
265
+ },
266
+ },
267
+ },
225
268
  },
226
269
  required: ["metadata"],
227
270
  additionalProperties: false,
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export { isModuleKind, MODULE_KINDS } from "./module-kinds.js";
14
14
  export type { ModuleKind } from "./module-kinds.js";
15
15
  export { parseLoadedFile } from "./parse-loaded-file.js";
16
16
  export type { ParseOptions } from "./parse-loaded-file.js";
17
+ export { residualEntrySchema, residualEntrySchemaMap } from "./residual-schema.js";
17
18
  export {
18
19
  buildDocumentPositions,
19
20
  buildLineOffsets,
@@ -1,4 +1,5 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
+ import { residualEntrySchemaMap } from "./residual-schema.js";
2
3
 
3
4
  /**
4
5
  * Kernel global names available in every CEL evaluation context at runtime.
@@ -72,20 +73,17 @@ export function buildKernelGlobalsSchema(
72
73
  }
73
74
 
74
75
  /** Wrap a JSON Schema property map (like `Telo.Application.variables`) into a
75
- * closed object schema suitable for chain-access validation. Falls back to
76
- * an open map when the module declares no variables/secrets. */
76
+ * closed object schema suitable for chain-access validation. For Application
77
+ * entries the per-entry shape carries kernel-specific keys (`env`, `default`)
78
+ * on top of an otherwise-standard JSON Schema property schema; those keys are
79
+ * stripped via `residualEntrySchemaMap` so CEL sees the coerced shape, not
80
+ * the env-binding wrapper. Library entries are pure JSON Schema property
81
+ * schemas and pass through the same call unchanged. Falls back to an open map
82
+ * when the module declares no variables/secrets. */
77
83
  function buildSchemaMapSchema(
78
84
  schemaMap: Record<string, any> | null | undefined,
79
85
  ): Record<string, any> {
80
- if (!schemaMap || typeof schemaMap !== "object" || Array.isArray(schemaMap)) {
81
- return { type: "object", additionalProperties: true };
82
- }
83
- const props: Record<string, any> = {};
84
- for (const [key, value] of Object.entries(schemaMap)) {
85
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
86
- props[key] = value;
87
- }
88
- }
86
+ const props = residualEntrySchemaMap(schemaMap);
89
87
  if (Object.keys(props).length === 0) {
90
88
  return { type: "object", additionalProperties: true };
91
89
  }
@@ -82,7 +82,14 @@ export function normalizeInlineResources(
82
82
  );
83
83
 
84
84
  const invocationContext = isRefEntry(entry) ? entry.context : undefined;
85
- const extracted = extractInlinesAtPath(resource, fieldPath, parentName, parentModule, invocationContext);
85
+ const extracted = extractInlinesAtPath(
86
+ resource,
87
+ fieldPath,
88
+ parentName,
89
+ resource.kind,
90
+ parentModule,
91
+ invocationContext,
92
+ );
86
93
  for (const manifest of extracted) {
87
94
  result.push(manifest);
88
95
  queue.push(manifest as ResourceManifest & { metadata: { name: string } });
@@ -99,18 +106,42 @@ export function normalizeInlineResources(
99
106
  * Walks `resource` following `fieldPath` (dot notation, `[]` = array traversal).
100
107
  * Mutates the resource in-place: replaces each inline value with `{kind, name}`.
101
108
  * Returns the extracted manifests.
109
+ *
110
+ * Each extracted manifest carries `metadata.xTeloOrigin` so downstream
111
+ * diagnostics can be rerouted back to the parent doc's YAML position:
112
+ * - `parentKind` / `parentName` — the resource that owned the inline slot
113
+ * - `pathFromParent` — concrete dotted path with `[N]` indices, matching
114
+ * `buildPositionIndex` keys (e.g. `routes[0].handler`)
102
115
  */
103
116
  function extractInlinesAtPath(
104
117
  resource: ResourceManifest,
105
118
  fieldPath: string,
106
119
  parentName: string,
120
+ parentKind: string,
107
121
  parentModule: string | undefined,
108
122
  invocationContext?: Record<string, any>,
109
123
  ): ResourceManifest[] {
110
124
  const extracted: ResourceManifest[] = [];
111
125
  const parts = fieldPath.split(".");
112
126
 
113
- function traverse(obj: unknown, partsLeft: string[], nameParts: string[]): void {
127
+ function emit(
128
+ inline: Record<string, unknown>,
129
+ nameSegments: string[],
130
+ concretePath: string,
131
+ ): string {
132
+ const name = sanitizeName([parentName, ...nameSegments].join("_"));
133
+ extracted.push(
134
+ buildManifest(inline, name, parentKind, parentName, concretePath, parentModule, invocationContext),
135
+ );
136
+ return name;
137
+ }
138
+
139
+ function traverse(
140
+ obj: unknown,
141
+ partsLeft: string[],
142
+ nameParts: string[],
143
+ pathSoFar: string,
144
+ ): void {
114
145
  if (!obj || typeof obj !== "object" || partsLeft.length === 0) return;
115
146
 
116
147
  const [head, ...rest] = partsLeft;
@@ -123,15 +154,15 @@ function extractInlinesAtPath(
123
154
  const elem = container[mapKey];
124
155
  if (!elem || typeof elem !== "object") continue;
125
156
  const sanitizedKey = sanitizeName(mapKey);
157
+ const childPath = pathSoFar ? `${pathSoFar}.${mapKey}` : mapKey;
126
158
 
127
159
  if (rest.length === 0) {
128
160
  if (isInlineResource(elem as Record<string, unknown>)) {
129
- const name = sanitizeName([parentName, ...nameParts, sanitizedKey].join("_"));
130
- extracted.push(buildManifest(elem as Record<string, unknown>, name, parentModule, invocationContext));
161
+ const name = emit(elem as Record<string, unknown>, [...nameParts, sanitizedKey], childPath);
131
162
  container[mapKey] = { kind: (elem as Record<string, unknown>).kind, name };
132
163
  }
133
164
  } else {
134
- traverse(elem, rest, [...nameParts, sanitizedKey]);
165
+ traverse(elem, rest, [...nameParts, sanitizedKey], childPath);
135
166
  }
136
167
  }
137
168
  return;
@@ -142,6 +173,7 @@ function extractInlinesAtPath(
142
173
  const container = obj as Record<string, unknown>;
143
174
  const val = container[key];
144
175
  if (val == null) return;
176
+ const keyPath = pathSoFar ? `${pathSoFar}.${key}` : key;
145
177
 
146
178
  if (isArr) {
147
179
  if (!Array.isArray(val)) return;
@@ -152,39 +184,41 @@ function extractInlinesAtPath(
152
184
  typeof (elem as Record<string, unknown>).name === "string"
153
185
  ? ((elem as Record<string, unknown>).name as string)
154
186
  : String(idx);
187
+ const childPath = `${keyPath}[${idx}]`;
155
188
 
156
189
  if (rest.length === 0) {
157
190
  // Array element itself is the ref slot
158
191
  if (isInlineResource(elem as Record<string, unknown>)) {
159
- const name = sanitizeName([parentName, ...nameParts, key, elemId].join("_"));
160
- extracted.push(buildManifest(elem as Record<string, unknown>, name, parentModule, invocationContext));
192
+ const name = emit(elem as Record<string, unknown>, [...nameParts, key, elemId], childPath);
161
193
  val[idx] = { kind: (elem as Record<string, unknown>).kind, name };
162
194
  }
163
195
  } else {
164
- traverse(elem, rest, [...nameParts, key, elemId]);
196
+ traverse(elem, rest, [...nameParts, key, elemId], childPath);
165
197
  }
166
198
  }
167
199
  } else {
168
200
  if (rest.length === 0) {
169
201
  // val is the ref slot
170
202
  if (val && typeof val === "object" && !Array.isArray(val) && isInlineResource(val as Record<string, unknown>)) {
171
- const name = sanitizeName([parentName, ...nameParts, key].join("_"));
172
- extracted.push(buildManifest(val as Record<string, unknown>, name, parentModule, invocationContext));
203
+ const name = emit(val as Record<string, unknown>, [...nameParts, key], keyPath);
173
204
  container[key] = { kind: (val as Record<string, unknown>).kind, name };
174
205
  }
175
206
  } else {
176
- traverse(val, rest, [...nameParts, key]);
207
+ traverse(val, rest, [...nameParts, key], keyPath);
177
208
  }
178
209
  }
179
210
  }
180
211
 
181
- traverse(resource, parts, []);
212
+ traverse(resource, parts, [], "");
182
213
  return extracted;
183
214
  }
184
215
 
185
216
  function buildManifest(
186
217
  inline: Record<string, unknown>,
187
218
  name: string,
219
+ parentKind: string,
220
+ parentName: string,
221
+ pathFromParent: string,
188
222
  parentModule: string | undefined,
189
223
  invocationContext?: Record<string, any>,
190
224
  ): ResourceManifest {
@@ -200,6 +234,7 @@ function buildManifest(
200
234
  // Inherit parent module only if the inline doesn't already declare one
201
235
  ...(parentModule && !existingMeta.module ? { module: parentModule } : {}),
202
236
  ...(invocationContext ? { xTeloInvocationContext: invocationContext } : {}),
237
+ xTeloOrigin: { parentKind, parentName, pathFromParent },
203
238
  },
204
- } as ResourceManifest;
239
+ } as unknown as ResourceManifest;
205
240
  }
@@ -63,7 +63,11 @@ function offsetToPosition(offset: number, lineOffsets: number[]): Position {
63
63
  }
64
64
 
65
65
  /** Walks the YAML AST and records source ranges for every field value, keyed
66
- * by dotted path (e.g. "kind", "config.handler", "config.routes[0].path"). */
66
+ * by dotted path (e.g. "kind", "config.handler", "config.routes[0].path").
67
+ * Map keys are also recorded under the `@key:<path>` namespace so diagnostic
68
+ * resolvers can squiggle just the key identifier instead of the full value
69
+ * block — used when a diagnostic targets a missing child property and the
70
+ * resolver has to fall back to the parent. */
67
71
  export function buildPositionIndex(doc: Document, lineOffsets: number[]): PositionIndex {
68
72
  const index: PositionIndex = new Map();
69
73
 
@@ -83,6 +87,9 @@ export function buildPositionIndex(doc: Document, lineOffsets: number[]): Positi
83
87
  const key = isScalar(pair.key) ? String(pair.key.value) : null;
84
88
  if (key == null) continue;
85
89
  const childPath = path ? `${path}.${key}` : key;
90
+ if (pair.key && (pair.key as any).range) {
91
+ recordNode(pair.key, `@key:${childPath}`);
92
+ }
86
93
  if (pair.value != null) {
87
94
  recordNode(pair.value, childPath);
88
95
  walk(pair.value, childPath);
@@ -63,23 +63,39 @@ export function isInlineResource(val: Record<string, unknown>): boolean {
63
63
  return true;
64
64
  }
65
65
 
66
- /** Resolves all values at a field map path in a resource config.
67
- * Path-segment markers:
68
- * - `[]` iterate array values at this key
66
+ /** A value found at a field-map path, paired with the concrete path that
67
+ * produced it. `path` has every `[]` substituted with `[N]` and every `{}`
68
+ * substituted with the actual map key, matching the format produced by
69
+ * `buildPositionIndex`. Used so diagnostics emitted against a specific
70
+ * array element / map entry can be resolved back to a YAML range. */
71
+ export interface ResolvedFieldEntry {
72
+ value: unknown;
73
+ path: string;
74
+ }
75
+
76
+ /** Resolves all `{value, path}` entries at a field map path in a resource
77
+ * config. The returned `path` is the concrete dotted path produced by the
78
+ * substitutions below, matching the format `buildPositionIndex` keys on.
79
+ * Path-segment markers accepted in the input `path`:
80
+ * - `[]` iterate array values at this key, substituting `[N]` per item
81
+ * (e.g. `routes[]` → `routes[0]`, `routes[1]`, …).
69
82
  * - `{}` iterate map values (every value in an `additionalProperties`-typed
70
83
  * object — used for fields like `content[mime]` whose schema declares
71
- * a key-as-MIME map). The path is `<key>.{}.<rest>`. */
72
- export function resolveFieldValues(obj: unknown, path: string): unknown[] {
84
+ * a key-as-MIME map). Substituted with the literal map key joined by
85
+ * a dot, so the input `content.{}.encoder` yields concrete paths
86
+ * like `content.application/json.encoder`. */
87
+ export function resolveFieldEntries(obj: unknown, path: string): ResolvedFieldEntry[] {
73
88
  const parts = path.split(".");
74
- let current: unknown[] = [obj];
89
+ let current: ResolvedFieldEntry[] = [{ value: obj, path: "" }];
75
90
  for (const part of parts) {
76
91
  if (part === "{}") {
77
- // Iterate the values of every map currently in `current`.
78
- const next: unknown[] = [];
79
- for (const item of current) {
80
- if (!item || typeof item !== "object") continue;
81
- for (const v of Object.values(item as Record<string, unknown>)) {
82
- if (v != null) next.push(v);
92
+ const next: ResolvedFieldEntry[] = [];
93
+ for (const entry of current) {
94
+ if (!entry.value || typeof entry.value !== "object") continue;
95
+ for (const [k, v] of Object.entries(entry.value as Record<string, unknown>)) {
96
+ if (v != null) {
97
+ next.push({ value: v, path: entry.path ? `${entry.path}.${k}` : k });
98
+ }
83
99
  }
84
100
  }
85
101
  current = next;
@@ -87,19 +103,31 @@ export function resolveFieldValues(obj: unknown, path: string): unknown[] {
87
103
  }
88
104
  const isArray = part.endsWith("[]");
89
105
  const key = isArray ? part.slice(0, -2) : part;
90
- const next: unknown[] = [];
91
- for (const item of current) {
92
- if (!item || typeof item !== "object") continue;
93
- const val = (item as Record<string, unknown>)[key];
106
+ const next: ResolvedFieldEntry[] = [];
107
+ for (const entry of current) {
108
+ if (!entry.value || typeof entry.value !== "object") continue;
109
+ const val = (entry.value as Record<string, unknown>)[key];
94
110
  if (val == null) continue;
95
- if (isArray && Array.isArray(val)) next.push(...val);
96
- else if (!isArray) next.push(val);
111
+ const basePath = entry.path ? `${entry.path}.${key}` : key;
112
+ if (isArray && Array.isArray(val)) {
113
+ for (let i = 0; i < val.length; i++) {
114
+ if (val[i] != null) next.push({ value: val[i], path: `${basePath}[${i}]` });
115
+ }
116
+ } else if (!isArray) {
117
+ next.push({ value: val, path: basePath });
118
+ }
97
119
  }
98
120
  current = next;
99
121
  }
100
122
  return current;
101
123
  }
102
124
 
125
+ /** Backwards-compat wrapper that drops the concrete path. Prefer
126
+ * `resolveFieldEntries` for new code that wants positions. */
127
+ export function resolveFieldValues(obj: unknown, path: string): unknown[] {
128
+ return resolveFieldEntries(obj, path).map((e) => e.value);
129
+ }
130
+
103
131
  /**
104
132
  * Traverses a definition's JSON Schema once and returns a field map recording every
105
133
  * x-telo-ref slot and every x-telo-scope slot.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Build the residual JSON Schema for a `variables` / `secrets` entry.
3
+ *
4
+ * For Telo.Application env-binding entries (those with an `env:` key), strips
5
+ * the kernel-specific wrapper keys `env` and `default` — `default` here is
6
+ * the *fallback host value* the kernel coerces when the env var is unset, not
7
+ * a JSON Schema annotation, so it must not leak into the validator.
8
+ *
9
+ * For Telo.Library entries (no `env:`), passes the entry through unchanged.
10
+ * Library `default:` is a standard JSON Schema annotation and stays.
11
+ *
12
+ * Single source of truth for "residual schema" referenced by both the
13
+ * analyzer's CEL globals normalization and the kernel's runtime env-var
14
+ * resolver — keeping them aligned prevents the two surfaces from drifting.
15
+ */
16
+ export function residualEntrySchema(
17
+ entry: Record<string, unknown> | null | undefined,
18
+ ): Record<string, unknown> {
19
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
20
+ return { type: "object", additionalProperties: true };
21
+ }
22
+ const isAppEnvBinding = "env" in entry;
23
+ const out: Record<string, unknown> = {};
24
+ for (const [key, value] of Object.entries(entry)) {
25
+ if (isAppEnvBinding && (key === "env" || key === "default")) continue;
26
+ out[key] = value;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Apply `residualEntrySchema` to every entry in a `variables` / `secrets` map.
33
+ * Returns a property-map suitable for use as the inner schema of CEL's
34
+ * `variables` / `secrets` namespaces.
35
+ */
36
+ export function residualEntrySchemaMap(
37
+ entries: Record<string, unknown> | null | undefined,
38
+ ): Record<string, Record<string, unknown>> {
39
+ const out: Record<string, Record<string, unknown>> = {};
40
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
41
+ return out;
42
+ }
43
+ for (const [name, value] of Object.entries(entries)) {
44
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
45
+ out[name] = residualEntrySchema(value as Record<string, unknown>);
46
+ }
47
+ }
48
+ return out;
49
+ }
@@ -0,0 +1,75 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+ import type { AnalysisDiagnostic } from "./types.js";
3
+
4
+ interface XTeloOrigin {
5
+ parentKind: string;
6
+ parentName: string;
7
+ pathFromParent: string;
8
+ }
9
+
10
+ function readOrigin(manifest: ResourceManifest | undefined): XTeloOrigin | undefined {
11
+ if (!manifest) return undefined;
12
+ const origin = (manifest.metadata as { xTeloOrigin?: XTeloOrigin } | undefined)?.xTeloOrigin;
13
+ if (
14
+ !origin ||
15
+ typeof origin.parentKind !== "string" ||
16
+ typeof origin.parentName !== "string" ||
17
+ typeof origin.pathFromParent !== "string"
18
+ ) {
19
+ return undefined;
20
+ }
21
+ return origin;
22
+ }
23
+
24
+ /** Diagnostics emitted on synthetic manifests (resources extracted by
25
+ * `normalizeInlineResources`) carry the synthetic's identity in
26
+ * `data.resource`, which has no YAML source. Rewrite each such diagnostic
27
+ * back to the chain root: walk up `metadata.xTeloOrigin` until a manifest
28
+ * with no origin is reached, and prepend each hop's `pathFromParent` to
29
+ * `data.path` so position-index lookups against the root doc resolve. */
30
+ export function rewriteSyntheticOrigins(
31
+ diagnostics: AnalysisDiagnostic[],
32
+ manifests: ResourceManifest[],
33
+ ): AnalysisDiagnostic[] {
34
+ const byName = new Map<string, ResourceManifest>();
35
+ for (const m of manifests) {
36
+ const name = m.metadata?.name;
37
+ if (typeof name === "string") byName.set(name, m);
38
+ }
39
+
40
+ return diagnostics.map((d) => {
41
+ const data = d.data as
42
+ | { resource?: { kind?: string; name?: string }; path?: string; filePath?: string }
43
+ | undefined;
44
+ if (!data?.resource?.name) return d;
45
+
46
+ let current = byName.get(data.resource.name);
47
+ let origin = readOrigin(current);
48
+ if (!origin) return d;
49
+
50
+ let accumPath = typeof data.path === "string" ? data.path : "";
51
+ let rootKind: string = origin.parentKind;
52
+ let rootName: string = origin.parentName;
53
+
54
+ while (origin) {
55
+ accumPath = accumPath ? `${origin.pathFromParent}.${accumPath}` : origin.pathFromParent;
56
+ rootKind = origin.parentKind;
57
+ rootName = origin.parentName;
58
+ current = byName.get(origin.parentName);
59
+ origin = readOrigin(current);
60
+ }
61
+
62
+ const rootFilePath =
63
+ (current?.metadata as { source?: string } | undefined)?.source ?? data.filePath;
64
+
65
+ return {
66
+ ...d,
67
+ data: {
68
+ ...data,
69
+ resource: { kind: rootKind, name: rootName },
70
+ filePath: rootFilePath,
71
+ path: accumPath,
72
+ },
73
+ };
74
+ });
75
+ }
@@ -119,6 +119,11 @@ export function pathMatchesScope(exprPath: string, scope: string): boolean {
119
119
  * `manifestRoot.provide.kind` as a kind name, looks up the kind's Telo.Definition,
120
120
  * and returns the `outputType` schema.
121
121
  *
122
+ * Accepts either a single string or an array of strings. With an array, paths
123
+ * are tried in order and the first one that resolves to a usable schema wins —
124
+ * used by `result:` to find its dispatch target under whichever entry-point
125
+ * field (`provide:` or `invoke:`) the definition declares.
126
+ *
122
127
  * - `x-telo-context-ref-from`: existing form — reads `{kind, name}` object from
123
128
  * `manifestItem.<path>`, looks up the named manifest, returns its `<subpath>` field.
124
129
  *
@@ -153,8 +158,16 @@ export function resolveContextAnnotations(
153
158
  }
154
159
 
155
160
  const fromRoot = schema["x-telo-context-from-root"] as string | undefined;
156
- const fromRefKind = schema["x-telo-context-from-ref-kind"] as string | undefined;
157
- if (fromRoot || fromRefKind) {
161
+ const fromRefKindRaw = schema["x-telo-context-from-ref-kind"] as
162
+ | string
163
+ | string[]
164
+ | undefined;
165
+ const fromRefKinds = fromRefKindRaw == null
166
+ ? []
167
+ : Array.isArray(fromRefKindRaw)
168
+ ? fromRefKindRaw
169
+ : [fromRefKindRaw];
170
+ if (fromRoot || fromRefKinds.length > 0) {
158
171
  if (fromRoot) {
159
172
  const resolved = navigatePath(manifestRoot, fromRoot.split("/")) as
160
173
  | Record<string, any>
@@ -163,22 +176,22 @@ export function resolveContextAnnotations(
163
176
  return resolved;
164
177
  }
165
178
  }
166
- if (fromRefKind && defs) {
167
- const hashIdx = fromRefKind.indexOf("#");
168
- if (hashIdx > 0) {
179
+ if (defs) {
180
+ for (const fromRefKind of fromRefKinds) {
181
+ const hashIdx = fromRefKind.indexOf("#");
182
+ if (hashIdx <= 0) continue;
169
183
  const refPath = fromRefKind.slice(0, hashIdx);
170
184
  const field = fromRefKind.slice(hashIdx + 1);
171
185
  const kindValue = navigatePath(manifestRoot, refPath.split("/"));
172
- if (typeof kindValue === "string" && kindValue.length > 0) {
173
- const canonical = aliases?.resolveKind(kindValue) ?? kindValue;
174
- const def = defs.resolve(canonical);
175
- const typeField = def
176
- ? (def as Record<string, unknown>)[field]
177
- : undefined;
178
- const resolved = resolveTypeFieldToSchema(typeField, allManifests ?? []);
179
- if (resolved && typeof resolved === "object") {
180
- return resolved;
181
- }
186
+ if (typeof kindValue !== "string" || kindValue.length === 0) continue;
187
+ const canonical = aliases?.resolveKind(kindValue) ?? kindValue;
188
+ const def = defs.resolve(canonical);
189
+ const typeField = def
190
+ ? (def as Record<string, unknown>)[field]
191
+ : undefined;
192
+ const resolved = resolveTypeFieldToSchema(typeField, allManifests ?? []);
193
+ if (resolved && typeof resolved === "object") {
194
+ return resolved;
182
195
  }
183
196
  }
184
197
  }