@telorun/analyzer 0.4.0 → 0.6.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 (40) hide show
  1. package/README.md +0 -4
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +62 -2
  4. package/dist/cel-environment.d.ts +11 -2
  5. package/dist/cel-environment.d.ts.map +1 -1
  6. package/dist/cel-environment.js +13 -2
  7. package/dist/index.d.ts +4 -4
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -3
  10. package/dist/manifest-loader.d.ts +5 -5
  11. package/dist/manifest-loader.d.ts.map +1 -1
  12. package/dist/manifest-loader.js +35 -35
  13. package/dist/normalize-inline-resources.js +22 -0
  14. package/dist/reference-field-map.d.ts +14 -3
  15. package/dist/reference-field-map.d.ts.map +1 -1
  16. package/dist/reference-field-map.js +39 -4
  17. package/dist/{adapters/http-adapter.d.ts → sources/http-source.d.ts} +3 -3
  18. package/dist/sources/http-source.d.ts.map +1 -0
  19. package/dist/{adapters/http-adapter.js → sources/http-source.js} +1 -1
  20. package/dist/{adapters/registry-adapter.d.ts → sources/registry-source.d.ts} +3 -3
  21. package/dist/sources/registry-source.d.ts.map +1 -0
  22. package/dist/{adapters/registry-adapter.js → sources/registry-source.js} +1 -1
  23. package/dist/types.d.ts +10 -10
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/validate-cel-context.d.ts +2 -1
  26. package/dist/validate-cel-context.d.ts.map +1 -1
  27. package/dist/validate-cel-context.js +30 -3
  28. package/package.json +2 -2
  29. package/src/analyzer.ts +81 -0
  30. package/src/cel-environment.ts +13 -3
  31. package/src/index.ts +7 -4
  32. package/src/manifest-loader.ts +34 -34
  33. package/src/normalize-inline-resources.ts +23 -0
  34. package/src/reference-field-map.ts +39 -4
  35. package/src/{adapters/http-adapter.ts → sources/http-source.ts} +2 -2
  36. package/src/{adapters/registry-adapter.ts → sources/registry-source.ts} +2 -2
  37. package/src/types.ts +10 -10
  38. package/src/validate-cel-context.ts +32 -3
  39. package/dist/adapters/http-adapter.d.ts.map +0 -1
  40. package/dist/adapters/registry-adapter.d.ts.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAEpD;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CA6BjC;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,EAAE,CAI7D;AAwED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,MAAM,EAAE,EACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,MAAM,GAAG,IAAI,CAmBf;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GACnC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAqDrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAQrB"}
1
+ {"version":3,"file":"validate-cel-context.d.ts","sourceRoot":"","sources":["../src/validate-cel-context.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAEpD;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,OAAO,EACd,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GAClC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CA6BjC;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,MAAM,EAAE,EAAE,CAI7D;AAsFD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,KAAK,EAAE,MAAM,EAAE,EACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC1B,MAAM,GAAG,IAAI,CAiCf;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAoBzE;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACjC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,GACnC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAqDrB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAC5B,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAQrB"}
@@ -90,7 +90,15 @@ function visitNode(node, chains, boundVars) {
90
90
  function isASTNode(v) {
91
91
  return v !== null && typeof v === "object" && "op" in v;
92
92
  }
93
- /** Returns the member-access chain for a node if it is purely an id or "." chain; else null. */
93
+ // Sentinel used in extracted chains to represent an `[index]` access. The
94
+ // validator treats it like any other deeper segment, so a chain that reaches
95
+ // past an `x-telo-stream`-marked property via `[N]` is rejected the same way
96
+ // `.field` access is. The actual index expression is opaque to the chain
97
+ // validator (it only checks property names against the schema).
98
+ const INDEX_SEGMENT = "[*]";
99
+ /** Returns the member-access chain for a node if it is purely an id, ".", or
100
+ * "[]" chain; else null. Bracket index access is captured as `INDEX_SEGMENT`
101
+ * so it counts as a chain extension for the stream-property opacity rule. */
94
102
  function extractChain(node, boundVars) {
95
103
  if (node.op === "id") {
96
104
  const name = node.args;
@@ -104,12 +112,19 @@ function extractChain(node, boundVars) {
104
112
  if (parent !== null)
105
113
  return [...parent, field];
106
114
  }
115
+ if (node.op === "[]") {
116
+ const [obj] = node.args;
117
+ const parent = extractChain(obj, boundVars);
118
+ if (parent !== null)
119
+ return [...parent, INDEX_SEGMENT];
120
+ }
107
121
  return null;
108
122
  }
109
123
  /**
110
124
  * Check whether a member-access chain accesses only fields declared in a JSON Schema.
111
125
  * Returns an error string if a field is unknown in a schema that declares explicit
112
- * properties without `additionalProperties: true`.
126
+ * properties without `additionalProperties: true`, or if the chain attempts to
127
+ * reach inside an `x-telo-stream: true` property.
113
128
  * Returns null when the chain is valid or the schema is too open to judge.
114
129
  */
115
130
  export function validateChainAgainstSchema(chain, schema) {
@@ -122,8 +137,20 @@ export function validateChainAgainstSchema(chain, schema) {
122
137
  if (!props)
123
138
  return null;
124
139
  if (key in props) {
140
+ const propSchema = props[key];
141
+ // Stream-typed properties are opaque: pass through by reference, no
142
+ // member access inside. Reaching `result.output` is fine; reaching
143
+ // `result.output.text` is a wiring mistake — the consumer either iterates
144
+ // the stream in a JS.Script step or pipes it through an Encoder.
145
+ if (propSchema &&
146
+ typeof propSchema === "object" &&
147
+ propSchema["x-telo-stream"] === true &&
148
+ i < chain.length - 1) {
149
+ const path = chain.slice(0, i + 1).join(".");
150
+ return `'${path}' yields a stream — pipe it through an Encoder or iterate in a JS.Script step (no member access on stream-typed values)`;
151
+ }
125
152
  // Known property — drill into it even if additionalProperties is true
126
- current = props[key];
153
+ current = propSchema;
127
154
  continue;
128
155
  }
129
156
  // Unknown property — only flag if schema is closed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -41,7 +41,7 @@
41
41
  "ajv-formats": "^3.0.1",
42
42
  "jsonpath-plus": "^10.3.0",
43
43
  "yaml": "^2.8.3",
44
- "@telorun/sdk": "0.5.0"
44
+ "@telorun/sdk": "0.7.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^20.0.0",
package/src/analyzer.ts CHANGED
@@ -34,6 +34,41 @@ import { validateThrowsCoverage } from "./validate-throws-coverage.js";
34
34
 
35
35
  const TEMPLATE_REGEX = /\$\{\{\s*([^}]+?)\s*\}\}/g;
36
36
 
37
+ const SELF_PREFIX = "Self.";
38
+
39
+ /** Resolve an alias-prefixed kind value (e.g. `Self.Encoder` or `Ai.Model`)
40
+ * to its canonical form. `Self.<Name>` resolves to `<ownModule>.<Name>` —
41
+ * the magic alias for "this library's own module" — and other prefixes
42
+ * resolve through the declaring file's Telo.Import aliases. */
43
+ function resolveSelfOrAlias(
44
+ value: string,
45
+ ownModule: string | undefined,
46
+ scopeResolver: AliasResolver,
47
+ ): string | undefined {
48
+ if (value.startsWith(SELF_PREFIX) && ownModule) {
49
+ return `${ownModule}.${value.slice(SELF_PREFIX.length)}`;
50
+ }
51
+ return scopeResolver.resolveKind(value);
52
+ }
53
+
54
+ /** Look up a top-level field (`outputType`, `inputType`) on a kind's
55
+ * `Telo.Definition`. Used as a fallback by `buildStepContextSchema` when the
56
+ * invoked resource manifest doesn't carry the field inline — most kinds
57
+ * declare result shape on the definition, not the resource. */
58
+ function lookupDefinitionTypeField(
59
+ invokedKind: string,
60
+ fieldName: string,
61
+ defs: DefinitionRegistry,
62
+ aliases: AliasResolver,
63
+ allManifests: Record<string, any>[],
64
+ ): Record<string, any> | undefined {
65
+ const canonical = aliases.resolveKind(invokedKind) ?? invokedKind;
66
+ const def = defs.resolve(canonical);
67
+ if (!def) return undefined;
68
+ const value = (def as unknown as Record<string, unknown>)[fieldName];
69
+ return resolveTypeFieldToSchema(value, allManifests);
70
+ }
71
+
37
72
  function walkCelExpressions(
38
73
  value: unknown,
39
74
  path: string,
@@ -95,11 +130,25 @@ function extractContextsFromSchema(
95
130
  * Build a `steps` context schema from `x-telo-step-context` annotation.
96
131
  * Walks each step in the manifest array, resolves the invoked resource's outputType,
97
132
  * and builds `steps.<name>.result` context entries.
133
+ *
134
+ * outputType resolution falls through three layers:
135
+ * 1. The invoked resource manifest's own `outputType` field (rare — most
136
+ * resources don't declare outputType inline).
137
+ * 2. The kind's `Telo.Definition` outputType (the common case for kinds that
138
+ * declare a stable result shape, e.g. `Ai.TextStream` ↦ `{output: stream}`).
139
+ * 3. Permissive `{type: object, additionalProperties: true}` if neither
140
+ * yields a schema.
141
+ *
142
+ * Layer 2 is what makes `x-telo-stream` properties on definitions actually
143
+ * govern step-result chain validation — without it, the validator falls back
144
+ * to permissive and the stream-opacity rule never fires.
98
145
  */
99
146
  function buildStepContextSchema(
100
147
  manifest: Record<string, any>,
101
148
  defSchema: Record<string, any>,
102
149
  allManifests: Record<string, any>[],
150
+ defs: DefinitionRegistry,
151
+ aliases: AliasResolver,
103
152
  ): Record<string, any> | undefined {
104
153
  const props = defSchema.properties as Record<string, any> | undefined;
105
154
  if (!props) return undefined;
@@ -139,6 +188,17 @@ function buildStepContextSchema(
139
188
  } else {
140
189
  outputSchema = resolveTypeFieldToSchema(invoke[outputTypeField], allManifests);
141
190
  }
191
+ // Fallback: pull outputType from the kind's Telo.Definition. The
192
+ // resource manifest typically doesn't carry outputType; the def does.
193
+ if (!outputSchema && invokedKind) {
194
+ outputSchema = lookupDefinitionTypeField(
195
+ invokedKind,
196
+ outputTypeField,
197
+ defs,
198
+ aliases,
199
+ allManifests,
200
+ );
201
+ }
142
202
  }
143
203
  stepProperties[name] = {
144
204
  type: "object",
@@ -317,6 +377,25 @@ export class StaticAnalyzer {
317
377
  const namespace = ((m.metadata as any).namespace as string | undefined) ?? null;
318
378
  const moduleName = m.metadata.name as string;
319
379
  if (moduleName) defs.registerModuleIdentity(namespace, moduleName);
380
+ // Auto-register `Self` as an alias for this library's own module name.
381
+ // Lets same-library `extends:` work (e.g. `extends: Self.Encoder` for a
382
+ // concrete kind whose abstract lives in the same Telo.Library) without
383
+ // requiring a self-import (which would loop the loader). Resolves
384
+ // through the same alias machinery as user-declared Telo.Imports —
385
+ // honours the library's `exports.kinds` list, no special cases.
386
+ if (moduleName) {
387
+ const exportedKinds: string[] = ((m as any).exports?.kinds as string[] | undefined) ?? [];
388
+ if (rootModules.has(moduleName)) {
389
+ aliases.registerImport("Self", moduleName, exportedKinds);
390
+ } else {
391
+ let libResolver = aliasesByModule.get(moduleName);
392
+ if (!libResolver) {
393
+ libResolver = new AliasResolver();
394
+ aliasesByModule.set(moduleName, libResolver);
395
+ }
396
+ libResolver.registerImport("Self", moduleName, exportedKinds);
397
+ }
398
+ }
320
399
  }
321
400
  if (m.kind === "Telo.Import") {
322
401
  const alias = m.metadata.name as string;
@@ -486,6 +565,8 @@ export class StaticAnalyzer {
486
565
  m as Record<string, any>,
487
566
  mDefinition.schema as Record<string, any>,
488
567
  allManifests as Record<string, any>[],
568
+ defs,
569
+ aliases,
489
570
  )
490
571
  : undefined;
491
572
 
@@ -1,5 +1,5 @@
1
1
  import { Environment } from "@marcbachmann/cel-js";
2
- import type { ResourceManifest } from "@telorun/sdk";
2
+ import { Stream, type ResourceManifest } from "@telorun/sdk";
3
3
  import { jsonSchemaToCelType } from "./schema-compat.js";
4
4
 
5
5
  export interface CelHandlers {
@@ -20,7 +20,16 @@ const STUB_HANDLERS: CelHandlers = {
20
20
  /** Build a CEL `Environment` with Telo's stdlib of functions. Always registers the
21
21
  * same function signatures (so `env.check()` succeeds for type-inference) — the
22
22
  * handlers govern what the function does when called at runtime. Analyzer-only
23
- * callers can omit handlers; runtime callers (kernel) must supply real ones. */
23
+ * callers can omit handlers; runtime callers (kernel) must supply real ones.
24
+ *
25
+ * Also registers the `Stream` object type, backed by the `Stream` class from
26
+ * `@telorun/sdk`. CEL's type-checker rejects values whose constructor isn't
27
+ * Object/Map/Array/Set/registered; producers that need to expose an
28
+ * `AsyncIterable` through a stream-typed property must wrap the iterable in
29
+ * `new Stream(...)` so its constructor is the registered class. The type has
30
+ * no fields, so terminal access (passing the value through CEL) succeeds but
31
+ * member access raises a CEL error at runtime — matching the analyzer's
32
+ * static check on `x-telo-stream`-marked properties. */
24
33
  export function buildCelEnvironment(handlers: CelHandlers = STUB_HANDLERS): Environment {
25
34
  return new Environment({ unlistedVariablesAreDyn: true })
26
35
  .registerFunction("join(list, string): string", (list: unknown[], sep: string) =>
@@ -34,7 +43,8 @@ export function buildCelEnvironment(handlers: CelHandlers = STUB_HANDLERS): Envi
34
43
  if (map instanceof Map) return [...map.values()];
35
44
  return Object.values(map as Record<string, unknown>);
36
45
  })
37
- .registerFunction("sha256(string): string", (s: string) => handlers.sha256(s));
46
+ .registerFunction("sha256(string): string", (s: string) => handlers.sha256(s))
47
+ .registerType("Stream", Stream as unknown as new (...args: unknown[]) => unknown);
38
48
  }
39
49
 
40
50
  /** Clone `baseEnv` and register typed variable declarations so that
package/src/index.ts CHANGED
@@ -1,14 +1,17 @@
1
- export { HttpAdapter } from "./adapters/http-adapter.js";
2
- export { RegistryAdapter } from "./adapters/registry-adapter.js";
3
1
  export { AnalysisRegistry } from "./analysis-registry.js";
4
2
  export { StaticAnalyzer } from "./analyzer.js";
5
3
  export { Loader } from "./manifest-loader.js";
6
- export { MODULE_KINDS, isModuleKind } from "./module-kinds.js";
4
+ export { isModuleKind, MODULE_KINDS } from "./module-kinds.js";
7
5
  export type { ModuleKind } from "./module-kinds.js";
6
+ export { HttpSource } from "./sources/http-source.js";
7
+ export { RegistrySource } from "./sources/registry-source.js";
8
8
  export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
9
9
  export type {
10
10
  AnalysisDiagnostic,
11
- AnalysisOptions, LoaderInitOptions, LoadOptions, ManifestAdapter,
11
+ AnalysisOptions,
12
+ LoaderInitOptions,
13
+ LoadOptions,
14
+ ManifestSource,
12
15
  Position,
13
16
  PositionIndex,
14
17
  Range
@@ -1,8 +1,8 @@
1
1
  import type { Environment } from "@marcbachmann/cel-js";
2
2
  import { isCompiledValue, type ResourceManifest } from "@telorun/sdk";
3
3
  import { isMap, isPair, isScalar, isSeq, parseAllDocuments, type Document } from "yaml";
4
- import { HttpAdapter } from "./adapters/http-adapter.js";
5
- import { RegistryAdapter } from "./adapters/registry-adapter.js";
4
+ import { HttpSource } from "./sources/http-source.js";
5
+ import { RegistrySource } from "./sources/registry-source.js";
6
6
  import { buildCelEnvironment } from "./cel-environment.js";
7
7
  import { isModuleKind } from "./module-kinds.js";
8
8
  import { precompileDoc } from "./precompile.js";
@@ -10,7 +10,7 @@ import {
10
10
  DEFAULT_MANIFEST_FILENAME,
11
11
  type LoadOptions,
12
12
  type LoaderInitOptions,
13
- type ManifestAdapter,
13
+ type ManifestSource,
14
14
  type Position,
15
15
  type PositionIndex,
16
16
  } from "./types.js";
@@ -23,42 +23,42 @@ const SYSTEM_KINDS = new Set([
23
23
  ]);
24
24
 
25
25
  export class Loader {
26
- private static readonly moduleCache = new Map<
26
+ private readonly moduleCache = new Map<
27
27
  string,
28
28
  { text: string; manifests: ResourceManifest[] }
29
29
  >();
30
30
 
31
- protected adapters: ManifestAdapter[];
31
+ protected sources: ManifestSource[];
32
32
  private readonly celEnv: Environment;
33
33
 
34
- constructor(extraAdaptersOrOptions: ManifestAdapter[] | LoaderInitOptions = []) {
35
- const options: LoaderInitOptions = Array.isArray(extraAdaptersOrOptions)
36
- ? { extraAdapters: extraAdaptersOrOptions }
37
- : extraAdaptersOrOptions;
34
+ constructor(extraSourcesOrOptions: ManifestSource[] | LoaderInitOptions = []) {
35
+ const options: LoaderInitOptions = Array.isArray(extraSourcesOrOptions)
36
+ ? { extraSources: extraSourcesOrOptions }
37
+ : extraSourcesOrOptions;
38
38
 
39
- const includeHttpAdapter = options.includeHttpAdapter ?? true;
40
- const includeRegistryAdapter = options.includeRegistryAdapter ?? true;
39
+ const includeHttpSource = options.includeHttpSource ?? true;
40
+ const includeRegistrySource = options.includeRegistrySource ?? true;
41
41
 
42
- this.adapters = [];
43
- if (includeHttpAdapter) this.adapters.push(new HttpAdapter());
44
- if (includeRegistryAdapter) this.adapters.push(new RegistryAdapter(options.registryUrl));
42
+ this.sources = [];
43
+ if (includeHttpSource) this.sources.push(new HttpSource());
44
+ if (includeRegistrySource) this.sources.push(new RegistrySource(options.registryUrl));
45
45
 
46
- if (options.extraAdapters?.length) {
47
- this.adapters.unshift(...options.extraAdapters);
46
+ if (options.extraSources?.length) {
47
+ this.sources.unshift(...options.extraSources);
48
48
  }
49
49
 
50
50
  this.celEnv = buildCelEnvironment(options.celHandlers);
51
51
  }
52
52
 
53
- register(adapter: ManifestAdapter): this {
54
- this.adapters.unshift(adapter);
53
+ register(source: ManifestSource): this {
54
+ this.sources.unshift(source);
55
55
  return this;
56
56
  }
57
57
 
58
- private pick(url: string): ManifestAdapter {
59
- const a = this.adapters.find((a) => a.supports(url));
60
- if (!a) throw new Error(`No adapter found for: ${url}`);
61
- return a;
58
+ private pick(url: string): ManifestSource {
59
+ const s = this.sources.find((s) => s.supports(url));
60
+ if (!s) throw new Error(`No source found for: ${url}`);
61
+ return s;
62
62
  }
63
63
 
64
64
  async resolveEntryPoint(url: string): Promise<string> {
@@ -69,7 +69,7 @@ export class Loader {
69
69
  async loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
70
70
  const { text, source } = await this.pick(url).read(url);
71
71
  const cacheKey = `${options?.compile ? "compiled" : "raw"}:${source}`;
72
- const cached = Loader.moduleCache.get(cacheKey);
72
+ const cached = this.moduleCache.get(cacheKey);
73
73
  if (cached && cached.text === text) {
74
74
  return cloneManifestArray(cached.manifests);
75
75
  }
@@ -152,8 +152,8 @@ export class Loader {
152
152
  const includePatterns = (moduleManifest as any).include as string[] | undefined;
153
153
  if (includePatterns?.length) {
154
154
  hasIncludes = true;
155
- const adapter = this.pick(source);
156
- const includedFiles = await this.resolveIncludes(source, includePatterns, adapter);
155
+ const picked = this.pick(source);
156
+ const includedFiles = await this.resolveIncludes(source, includePatterns, picked);
157
157
  for (const includedUrl of includedFiles) {
158
158
  const partialManifests = await this.loadPartialFile(includedUrl, moduleName, options);
159
159
  resolved.push(...partialManifests);
@@ -162,7 +162,7 @@ export class Loader {
162
162
  }
163
163
 
164
164
  if (!hasIncludes) {
165
- Loader.moduleCache.set(cacheKey, { text, manifests: resolved });
165
+ this.moduleCache.set(cacheKey, { text, manifests: resolved });
166
166
  }
167
167
  return cloneManifestArray(resolved);
168
168
  }
@@ -170,21 +170,21 @@ export class Loader {
170
170
  private async resolveIncludes(
171
171
  ownerSource: string,
172
172
  patterns: string[],
173
- adapter: ManifestAdapter,
173
+ source: ManifestSource,
174
174
  ): Promise<string[]> {
175
175
  const hasGlobs = patterns.some((p) => /[*?{}\[\]]/.test(p));
176
176
  if (hasGlobs) {
177
- if (!adapter.expandGlob) {
177
+ if (!source.expandGlob) {
178
178
  throw new Error(
179
- `Include patterns in '${ownerSource}' contain globs but the adapter for this source ` +
179
+ `Include patterns in '${ownerSource}' contain globs but the source for this URL ` +
180
180
  `does not support glob expansion. Use explicit file paths instead of patterns like: ` +
181
181
  patterns.filter((p) => /[*?{}\[\]]/.test(p)).join(", "),
182
182
  );
183
183
  }
184
- return adapter.expandGlob(ownerSource, patterns);
184
+ return source.expandGlob(ownerSource, patterns);
185
185
  }
186
186
  // Literal relative paths — deduplicate in case the same file appears under multiple patterns.
187
- return [...new Set(patterns.map((p) => adapter.resolveRelative(ownerSource, p)))];
187
+ return [...new Set(patterns.map((p) => source.resolveRelative(ownerSource, p)))];
188
188
  }
189
189
 
190
190
  private async loadPartialFile(
@@ -280,9 +280,9 @@ export class Loader {
280
280
  }
281
281
 
282
282
  // Find the owning telo.yaml via parent-directory traversal
283
- const adapter = this.pick(fileUrl);
284
- if (!adapter.resolveOwnerOf) return null;
285
- const ownerUrl = await adapter.resolveOwnerOf(fileUrl);
283
+ const source = this.pick(fileUrl);
284
+ if (!source.resolveOwnerOf) return null;
285
+ const ownerUrl = await source.resolveOwnerOf(fileUrl);
286
286
  if (!ownerUrl) return null;
287
287
 
288
288
  // Load the owner module (which will load included files via include expansion)
@@ -107,6 +107,29 @@ function extractInlinesAtPath(
107
107
  if (!obj || typeof obj !== "object" || partsLeft.length === 0) return;
108
108
 
109
109
  const [head, ...rest] = partsLeft;
110
+
111
+ // Map iteration: descend into every value of the current object (used for
112
+ // schema fields with `additionalProperties` like `content[mime]`).
113
+ if (head === "{}") {
114
+ const container = obj as Record<string, unknown>;
115
+ for (const mapKey of Object.keys(container)) {
116
+ const elem = container[mapKey];
117
+ if (!elem || typeof elem !== "object") continue;
118
+ const sanitizedKey = sanitizeName(mapKey);
119
+
120
+ if (rest.length === 0) {
121
+ if (isInlineResource(elem as Record<string, unknown>)) {
122
+ const name = sanitizeName([parentName, ...nameParts, sanitizedKey].join("_"));
123
+ extracted.push(buildManifest(elem as Record<string, unknown>, name, parentModule, invocationContext));
124
+ container[mapKey] = { kind: (elem as Record<string, unknown>).kind, name };
125
+ }
126
+ } else {
127
+ traverse(elem, rest, [...nameParts, sanitizedKey]);
128
+ }
129
+ }
130
+ return;
131
+ }
132
+
110
133
  const isArr = head.endsWith("[]");
111
134
  const key = isArr ? head.slice(0, -2) : head;
112
135
  const container = obj as Record<string, unknown>;
@@ -48,19 +48,43 @@ export function isSchemaFromEntry(entry: FieldMapEntry): entry is SchemaFromFiel
48
48
  export const REFERENCE_KEYS = new Set(["kind", "name", "metadata"]);
49
49
 
50
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. */
51
+ * Three shapes flow through here:
52
+ * - `{kind, name}` (optionally with runtime call args) named reference, NOT inline.
53
+ * - `{kind, ...config}` with no name → inline definition with config; extract.
54
+ * - `{kind}` alone (bare kind, no name) → inline singleton — extract a fresh
55
+ * stateless resource. Lets simple stateless kinds be used inline without
56
+ * boilerplate (e.g. `encoder: {kind: Ndjson.Encoder}`, `invoke: {kind: Run.Throw}`).
57
+ *
58
+ * A named reference (has string `name`) may carry extra keys (e.g. `inputs`)
59
+ * that are runtime call parameters — those are never inline resources. */
53
60
  export function isInlineResource(val: Record<string, unknown>): boolean {
54
61
  if (typeof val.name === "string") return false;
55
- return Object.keys(val).some((k) => !REFERENCE_KEYS.has(k));
62
+ if (typeof val.kind !== "string") return false;
63
+ return true;
56
64
  }
57
65
 
58
66
  /** Resolves all values at a field map path in a resource config.
59
- * `[]` in a path segment means "iterate array at this key". */
67
+ * Path-segment markers:
68
+ * - `[]` iterate array values at this key
69
+ * - `{}` iterate map values (every value in an `additionalProperties`-typed
70
+ * object — used for fields like `content[mime]` whose schema declares
71
+ * a key-as-MIME map). The path is `<key>.{}.<rest>`. */
60
72
  export function resolveFieldValues(obj: unknown, path: string): unknown[] {
61
73
  const parts = path.split(".");
62
74
  let current: unknown[] = [obj];
63
75
  for (const part of parts) {
76
+ 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);
83
+ }
84
+ }
85
+ current = next;
86
+ continue;
87
+ }
64
88
  const isArray = part.endsWith("[]");
65
89
  const key = isArray ? part.slice(0, -2) : part;
66
90
  const next: unknown[] = [];
@@ -144,4 +168,15 @@ function traverseNode(node: Record<string, any>, path: string, map: ReferenceFie
144
168
  traverseNode(propSchema as Record<string, any>, `${path}.${key}`, map);
145
169
  }
146
170
  }
171
+
172
+ // Map — `additionalProperties: { ... }` describes every value in an
173
+ // open-keyed object. Encoder refs nested inside `content[mime]` map
174
+ // entries reach Phase 5 through this branch.
175
+ if (
176
+ node.additionalProperties &&
177
+ typeof node.additionalProperties === "object" &&
178
+ !Array.isArray(node.additionalProperties)
179
+ ) {
180
+ traverseNode(node.additionalProperties as Record<string, any>, `${path}.{}`, map);
181
+ }
147
182
  }
@@ -1,6 +1,6 @@
1
- import { DEFAULT_MANIFEST_FILENAME, type ManifestAdapter } from "../types.js";
1
+ import { DEFAULT_MANIFEST_FILENAME, type ManifestSource } from "../types.js";
2
2
 
3
- export class HttpAdapter implements ManifestAdapter {
3
+ export class HttpSource implements ManifestSource {
4
4
  supports(url: string): boolean {
5
5
  return url.startsWith("http://") || url.startsWith("https://");
6
6
  }
@@ -1,8 +1,8 @@
1
- import { DEFAULT_MANIFEST_FILENAME, type ManifestAdapter } from "../types.js";
1
+ import { DEFAULT_MANIFEST_FILENAME, type ManifestSource } from "../types.js";
2
2
 
3
3
  const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
4
4
 
5
- export class RegistryAdapter implements ManifestAdapter {
5
+ export class RegistrySource implements ManifestSource {
6
6
  constructor(private registryUrl = DEFAULT_REGISTRY_URL) {}
7
7
 
8
8
  supports(url: string): boolean {
package/src/types.ts CHANGED
@@ -41,19 +41,19 @@ export interface AnalysisDiagnostic {
41
41
  data?: unknown;
42
42
  }
43
43
 
44
- export interface ManifestAdapter {
44
+ export interface ManifestSource {
45
45
  supports(url: string): boolean;
46
46
  read(url: string): Promise<{ text: string; source: string }>;
47
47
  resolveRelative(base: string, relative: string): string;
48
48
 
49
49
  /** Expand glob patterns relative to a base source. Returns sources in the same
50
50
  * format as read().source — suitable to pass back into read() / resolveRelative().
51
- * Optional — only filesystem-capable adapters implement this. */
51
+ * Optional — only filesystem-capable sources implement this. */
52
52
  expandGlob?(base: string, patterns: string[]): Promise<string[]>;
53
53
 
54
54
  /** Walk parent directories from fileUrl looking for the nearest telo.yaml.
55
55
  * Returns the source in the same format as read().source, or null if none found.
56
- * Optional — only filesystem-capable adapters implement this. */
56
+ * Optional — only filesystem-capable sources implement this. */
57
57
  resolveOwnerOf?(fileUrl: string): Promise<string | null>;
58
58
  }
59
59
 
@@ -66,13 +66,13 @@ export interface LoadOptions {
66
66
  }
67
67
 
68
68
  export interface LoaderInitOptions {
69
- /** Adapters inserted with highest priority before built-ins. */
70
- extraAdapters?: ManifestAdapter[];
71
- /** Include built-in HttpAdapter. Defaults to true. */
72
- includeHttpAdapter?: boolean;
73
- /** Include built-in RegistryAdapter. Defaults to true. */
74
- includeRegistryAdapter?: boolean;
75
- /** Base URL used by built-in RegistryAdapter when enabled. */
69
+ /** Sources inserted with highest priority before built-ins. */
70
+ extraSources?: ManifestSource[];
71
+ /** Include built-in HttpSource. Defaults to true. */
72
+ includeHttpSource?: boolean;
73
+ /** Include built-in RegistrySource. Defaults to true. */
74
+ includeRegistrySource?: boolean;
75
+ /** Base URL used by built-in RegistrySource when enabled. */
76
76
  registryUrl?: string;
77
77
  /** Handlers for CEL stdlib functions (e.g. `sha256`). Analyzer-only callers may
78
78
  * omit this and get throwing stubs; runtime callers (kernel) must supply real impls. */
@@ -107,7 +107,16 @@ function isASTNode(v: unknown): v is ASTNode {
107
107
  return v !== null && typeof v === "object" && "op" in (v as object);
108
108
  }
109
109
 
110
- /** Returns the member-access chain for a node if it is purely an id or "." chain; else null. */
110
+ // Sentinel used in extracted chains to represent an `[index]` access. The
111
+ // validator treats it like any other deeper segment, so a chain that reaches
112
+ // past an `x-telo-stream`-marked property via `[N]` is rejected the same way
113
+ // `.field` access is. The actual index expression is opaque to the chain
114
+ // validator (it only checks property names against the schema).
115
+ const INDEX_SEGMENT = "[*]";
116
+
117
+ /** Returns the member-access chain for a node if it is purely an id, ".", or
118
+ * "[]" chain; else null. Bracket index access is captured as `INDEX_SEGMENT`
119
+ * so it counts as a chain extension for the stream-property opacity rule. */
111
120
  function extractChain(node: ASTNode, boundVars: Set<string>): string[] | null {
112
121
  if (node.op === "id") {
113
122
  const name = node.args as string;
@@ -119,13 +128,19 @@ function extractChain(node: ASTNode, boundVars: Set<string>): string[] | null {
119
128
  const parent = extractChain(obj, boundVars);
120
129
  if (parent !== null) return [...parent, field];
121
130
  }
131
+ if (node.op === "[]") {
132
+ const [obj] = node.args as [ASTNode, ASTNode];
133
+ const parent = extractChain(obj, boundVars);
134
+ if (parent !== null) return [...parent, INDEX_SEGMENT];
135
+ }
122
136
  return null;
123
137
  }
124
138
 
125
139
  /**
126
140
  * Check whether a member-access chain accesses only fields declared in a JSON Schema.
127
141
  * Returns an error string if a field is unknown in a schema that declares explicit
128
- * properties without `additionalProperties: true`.
142
+ * properties without `additionalProperties: true`, or if the chain attempts to
143
+ * reach inside an `x-telo-stream: true` property.
129
144
  * Returns null when the chain is valid or the schema is too open to judge.
130
145
  */
131
146
  export function validateChainAgainstSchema(
@@ -139,8 +154,22 @@ export function validateChainAgainstSchema(
139
154
  const props: Record<string, any> | undefined = current.properties;
140
155
  if (!props) return null;
141
156
  if (key in props) {
157
+ const propSchema = props[key];
158
+ // Stream-typed properties are opaque: pass through by reference, no
159
+ // member access inside. Reaching `result.output` is fine; reaching
160
+ // `result.output.text` is a wiring mistake — the consumer either iterates
161
+ // the stream in a JS.Script step or pipes it through an Encoder.
162
+ if (
163
+ propSchema &&
164
+ typeof propSchema === "object" &&
165
+ propSchema["x-telo-stream"] === true &&
166
+ i < chain.length - 1
167
+ ) {
168
+ const path = chain.slice(0, i + 1).join(".");
169
+ return `'${path}' yields a stream — pipe it through an Encoder or iterate in a JS.Script step (no member access on stream-typed values)`;
170
+ }
142
171
  // Known property — drill into it even if additionalProperties is true
143
- current = props[key];
172
+ current = propSchema;
144
173
  continue;
145
174
  }
146
175
  // Unknown property — only flag if schema is closed
@@ -1 +0,0 @@
1
- {"version":3,"file":"http-adapter.d.ts","sourceRoot":"","sources":["../../src/adapters/http-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9E,qBAAa,WAAY,YAAW,eAAe;IACjD,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIxB,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAWlE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;CAIxD"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"registry-adapter.d.ts","sourceRoot":"","sources":["../../src/adapters/registry-adapter.ts"],"names":[],"mappings":"AAAA,OAAO,EAA6B,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAI9E,qBAAa,eAAgB,YAAW,eAAe;IACzC,OAAO,CAAC,WAAW;gBAAX,WAAW,SAAuB;IAEtD,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAWxB,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IAWxE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM;IAMvD,OAAO,CAAC,oBAAoB;IAM5B,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,cAAc;CAmBvB"}