@telorun/analyzer 0.1.2 → 0.1.3

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 (44) hide show
  1. package/README.md +233 -0
  2. package/dist/adapters/http-adapter.d.ts +1 -1
  3. package/dist/adapters/http-adapter.d.ts.map +1 -1
  4. package/dist/adapters/http-adapter.js +2 -1
  5. package/dist/adapters/node-adapter.d.ts +1 -1
  6. package/dist/adapters/node-adapter.d.ts.map +1 -1
  7. package/dist/adapters/node-adapter.js +2 -1
  8. package/dist/adapters/registry-adapter.d.ts +1 -1
  9. package/dist/adapters/registry-adapter.d.ts.map +1 -1
  10. package/dist/adapters/registry-adapter.js +2 -1
  11. package/dist/analyzer.d.ts.map +1 -1
  12. package/dist/analyzer.js +6 -1
  13. package/dist/cel-environment.d.ts +5 -2
  14. package/dist/cel-environment.d.ts.map +1 -1
  15. package/dist/cel-environment.js +5 -3
  16. package/dist/index.d.ts +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/kernel-globals.d.ts +34 -0
  20. package/dist/kernel-globals.d.ts.map +1 -0
  21. package/dist/kernel-globals.js +94 -0
  22. package/dist/manifest-loader.d.ts +2 -1
  23. package/dist/manifest-loader.d.ts.map +1 -1
  24. package/dist/manifest-loader.js +33 -1
  25. package/dist/schema-compat.d.ts +1 -1
  26. package/dist/schema-compat.d.ts.map +1 -1
  27. package/dist/schema-compat.js +43 -14
  28. package/dist/types.d.ts +2 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/types.js +2 -0
  31. package/dist/validate-references.d.ts.map +1 -1
  32. package/dist/validate-references.js +13 -1
  33. package/package.json +21 -2
  34. package/src/adapters/http-adapter.ts +2 -2
  35. package/src/adapters/node-adapter.ts +2 -2
  36. package/src/adapters/registry-adapter.ts +2 -2
  37. package/src/analyzer.ts +7 -1
  38. package/src/cel-environment.ts +5 -3
  39. package/src/index.ts +1 -1
  40. package/src/kernel-globals.ts +110 -0
  41. package/src/manifest-loader.ts +40 -2
  42. package/src/schema-compat.ts +47 -13
  43. package/src/types.ts +3 -0
  44. package/src/validate-references.ts +13 -1
@@ -11,6 +11,7 @@ export function createAjv() {
11
11
  return instance;
12
12
  }
13
13
  const ajv = createAjv();
14
+ const compiledSchemaValidators = new WeakMap();
14
15
  /** Conservative structural JSON Schema compatibility check.
15
16
  * Only flags definite mismatches: missing required fields and primitive type conflicts.
16
17
  * Ambiguous cases (anyOf/oneOf/etc.) are treated as compatible. */
@@ -93,12 +94,15 @@ function ajvErrorToPath(err) {
93
94
  }
94
95
  /** Validate actual data against a JSON Schema. Returns issues with path info, or empty array if valid. */
95
96
  export function validateAgainstSchema(data, schema) {
96
- let validate;
97
- try {
98
- validate = ajv.compile(schema);
99
- }
100
- catch {
101
- return [];
97
+ let validate = compiledSchemaValidators.get(schema);
98
+ if (!validate) {
99
+ try {
100
+ validate = ajv.compile(schema);
101
+ compiledSchemaValidators.set(schema, validate);
102
+ }
103
+ catch {
104
+ return [];
105
+ }
102
106
  }
103
107
  if (validate(data))
104
108
  return [];
@@ -234,24 +238,49 @@ export function celPlaceholderForSchema(schema) {
234
238
  }
235
239
  }
236
240
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
241
+ /** Resolve a `$ref` (only `#/$defs/...` form) against the root schema. */
242
+ function resolveRef(schema, root) {
243
+ if (schema.$ref && typeof schema.$ref === "string" && schema.$ref.startsWith("#/$defs/")) {
244
+ const defName = schema.$ref.slice("#/$defs/".length);
245
+ const resolved = root.$defs?.[defName];
246
+ if (resolved)
247
+ return resolved;
248
+ }
249
+ return schema;
250
+ }
251
+ /** Collect property schemas from top-level `properties` and all `oneOf`/`anyOf` sub-schemas. */
252
+ function collectProperties(schema) {
253
+ const props = { ...(schema.properties ?? {}) };
254
+ for (const sub of schema.oneOf ?? schema.anyOf ?? []) {
255
+ if (sub && typeof sub === "object" && sub.properties) {
256
+ for (const [k, v] of Object.entries(sub.properties)) {
257
+ if (!(k in props))
258
+ props[k] = v;
259
+ }
260
+ }
261
+ }
262
+ return props;
263
+ }
237
264
  /** Deep-clone `data`, replacing every pure CEL template string (`${{ expr }}`) with a
238
265
  * schema-appropriate placeholder so AJV can validate non-CEL fields without false positives. */
239
- export function substituteCelFields(data, schema) {
266
+ export function substituteCelFields(data, schema, rootSchema) {
267
+ const root = rootSchema ?? schema;
268
+ const resolved = resolveRef(schema, root);
240
269
  if (typeof data === "string" && CEL_PURE_RE.test(data)) {
241
- return celPlaceholderForSchema(schema);
270
+ return celPlaceholderForSchema(resolved);
242
271
  }
243
272
  if (Array.isArray(data)) {
244
- const itemSchema = (schema.items ?? {});
245
- return data.map((item) => substituteCelFields(item, itemSchema));
273
+ const itemSchema = resolveRef((resolved.items ?? {}), root);
274
+ return data.map((item) => substituteCelFields(item, itemSchema, root));
246
275
  }
247
276
  if (data !== null && typeof data === "object") {
248
- const props = (schema.properties ?? {});
249
- const addlProps = schema.additionalProperties && typeof schema.additionalProperties === "object"
250
- ? schema.additionalProperties
277
+ const props = collectProperties(resolved);
278
+ const addlProps = resolved.additionalProperties && typeof resolved.additionalProperties === "object"
279
+ ? resolved.additionalProperties
251
280
  : undefined;
252
281
  const result = {};
253
282
  for (const [k, v] of Object.entries(data)) {
254
- result[k] = substituteCelFields(v, (props[k] ?? addlProps ?? {}));
283
+ result[k] = substituteCelFields(v, (props[k] ?? addlProps ?? {}), root);
255
284
  }
256
285
  return result;
257
286
  }
package/dist/types.d.ts CHANGED
@@ -7,6 +7,8 @@ export declare const DiagnosticSeverity: {
7
7
  readonly Hint: 4;
8
8
  };
9
9
  export type DiagnosticSeverity = (typeof DiagnosticSeverity)[keyof typeof DiagnosticSeverity];
10
+ /** Default entry-point filename when a directory is given instead of a file. */
11
+ export declare const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
10
12
  export interface Position {
11
13
  /** 0-based line number */
12
14
  line: number;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;qHACqH;AACrH,eAAO,MAAM,kBAAkB;;;;;CAKrB,CAAC;AACX,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,OAAO,kBAAkB,CAAC,CAAC;AAE9F,MAAM,WAAW,QAAQ;IACvB,0BAA0B;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,QAAQ,CAAC;IAChB,GAAG,EAAE,QAAQ,CAAC;CACf;AAED;;oDAEoD;AACpD,MAAM,MAAM,aAAa,GAAG,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAE/C;6EAC6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;CACzD;AAED,MAAM,WAAW,WAAW;IAC1B;;;+EAG2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,aAAa,CAAC,EAAE,eAAe,EAAE,CAAC;IAClC,sDAAsD;IACtD,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,0DAA0D;IAC1D,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,8DAA8D;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;gEAKgE;AAChE,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC;IACtD,WAAW,CAAC,EAAE,OAAO,0BAA0B,EAAE,kBAAkB,CAAC;CACrE"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;qHACqH;AACrH,eAAO,MAAM,kBAAkB;;;;;CAKrB,CAAC;AACX,MAAM,MAAM,kBAAkB,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,OAAO,kBAAkB,CAAC,CAAC;AAE9F,gFAAgF;AAChF,eAAO,MAAM,yBAAyB,cAAc,CAAC;AAErD,MAAM,WAAW,QAAQ;IACvB,0BAA0B;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,QAAQ,CAAC;IAChB,GAAG,EAAE,QAAQ,CAAC;CACf;AAED;;oDAEoD;AACpD,MAAM,MAAM,aAAa,GAAG,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAE/C;6EAC6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,QAAQ,CAAC,EAAE,kBAAkB,CAAC;IAC9B,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,2BAA2B;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7D,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;CACzD;AAED,MAAM,WAAW,WAAW;IAC1B;;;+EAG2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,aAAa,CAAC,EAAE,eAAe,EAAE,CAAC;IAClC,sDAAsD;IACtD,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,0DAA0D;IAC1D,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,8DAA8D;IAC9D,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED;;;;;gEAKgE;AAChE,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,OAAO,qBAAqB,EAAE,aAAa,CAAC;IACtD,WAAW,CAAC,EAAE,OAAO,0BAA0B,EAAE,kBAAkB,CAAC;CACrE"}
package/dist/types.js CHANGED
@@ -6,3 +6,5 @@ export const DiagnosticSeverity = {
6
6
  Information: 3,
7
7
  Hint: 4,
8
8
  };
9
+ /** Default entry-point filename when a directory is given instead of a file. */
10
+ export const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
@@ -1 +1 @@
1
- {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA6C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CAoPtB"}
1
+ {"version":3,"file":"validate-references.d.ts","sourceRoot":"","sources":["../src/validate-references.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrD,OAAO,EAAsB,KAAK,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AA6C/F;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,gBAAgB,EAAE,EAC7B,OAAO,EAAE,eAAe,GACvB,kBAAkB,EAAE,CAgQtB"}
@@ -111,9 +111,21 @@ export function validateReferences(resources, context) {
111
111
  if (!val)
112
112
  continue;
113
113
  // Name-only reference (plain string) — look up by name to validate.
114
+ // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
115
+ // extract the resource name from the last dot segment.
114
116
  if (typeof val === "string") {
115
- const target = byName.get(val) ?? visibleScopeManifests.find((m) => m.metadata?.name === val);
117
+ const lastDot = val.lastIndexOf(".");
118
+ const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
119
+ const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
120
+ const target = byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
116
121
  if (!target) {
122
+ // Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
123
+ // The resource lives in the imported module's scope and can't be validated here.
124
+ // Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
125
+ // kinds — those must be validated.
126
+ if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
127
+ continue;
128
+ }
117
129
  diagnostics.push({
118
130
  severity: DiagnosticSeverity.Error,
119
131
  code: "UNRESOLVED_REFERENCE",
package/package.json CHANGED
@@ -1,6 +1,25 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
+ "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
+ "keywords": [
6
+ "telo",
7
+ "analyzer",
8
+ "validator",
9
+ "manifest",
10
+ "yaml"
11
+ ],
12
+ "author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
13
+ "license": "SEE LICENSE IN LICENSE",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/telorun/telo.git",
17
+ "directory": "analyzer/nodejs"
18
+ },
19
+ "homepage": "https://github.com/telorun/telo#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/telorun/telo/issues"
22
+ },
4
23
  "type": "module",
5
24
  "main": "./dist/index.js",
6
25
  "exports": {
@@ -22,7 +41,7 @@
22
41
  "ajv-formats": "^3.0.1",
23
42
  "yaml": "^2.8.3",
24
43
  "jsonpath-plus": "^10.3.0",
25
- "@telorun/sdk": "0.2.7"
44
+ "@telorun/sdk": "0.2.8"
26
45
  },
27
46
  "devDependencies": {
28
47
  "@types/node": "^20.0.0",
@@ -1,4 +1,4 @@
1
- import type { ManifestAdapter } from "../types.js";
1
+ import { DEFAULT_MANIFEST_FILENAME, type ManifestAdapter } from "../types.js";
2
2
 
3
3
  export class HttpAdapter implements ManifestAdapter {
4
4
  supports(url: string): boolean {
@@ -6,7 +6,7 @@ export class HttpAdapter implements ManifestAdapter {
6
6
  }
7
7
 
8
8
  async read(url: string): Promise<{ text: string; source: string }> {
9
- const fetchUrl = url.includes(".yaml") ? url : `${url}/module.yaml`;
9
+ const fetchUrl = url.includes(".yaml") ? url : `${url}/${DEFAULT_MANIFEST_FILENAME}`;
10
10
  const response = await fetch(fetchUrl);
11
11
  if (!response.ok) {
12
12
  throw new Error(
@@ -1,6 +1,6 @@
1
1
  import * as fs from "fs/promises";
2
2
  import * as path from "path";
3
- import type { ManifestAdapter } from "../types.js";
3
+ import { DEFAULT_MANIFEST_FILENAME, type ManifestAdapter } from "../types.js";
4
4
 
5
5
  /** Node.js fs-based ManifestAdapter for local files. Not browser-compatible. */
6
6
  export class NodeAdapter implements ManifestAdapter {
@@ -20,7 +20,7 @@ export class NodeAdapter implements ManifestAdapter {
20
20
  const filePath = url.startsWith("file://") ? new URL(url).pathname : url;
21
21
  const stat = await fs.stat(filePath).catch(() => null);
22
22
  const resolvedPath =
23
- stat?.isDirectory() ? path.join(filePath, "module.yaml") : filePath;
23
+ stat?.isDirectory() ? path.join(filePath, DEFAULT_MANIFEST_FILENAME) : filePath;
24
24
  const text = await fs.readFile(resolvedPath, "utf8");
25
25
  return { text, source: resolvedPath };
26
26
  }
@@ -1,4 +1,4 @@
1
- import type { ManifestAdapter } from "../types.js";
1
+ import { DEFAULT_MANIFEST_FILENAME, type ManifestAdapter } from "../types.js";
2
2
 
3
3
  const DEFAULT_REGISTRY_URL = "https://registry.telo.run";
4
4
 
@@ -40,7 +40,7 @@ export class RegistryAdapter implements ManifestAdapter {
40
40
  }
41
41
 
42
42
  private toRegistryUrl(moduleRef: string): string {
43
- return `${this.toRegistryModuleBase(moduleRef)}/module.yaml`;
43
+ return `${this.toRegistryModuleBase(moduleRef)}/${DEFAULT_MANIFEST_FILENAME}`;
44
44
  }
45
45
 
46
46
  private parseModuleRef(moduleRef: string): { modulePath: string; version: string } {
package/src/analyzer.ts CHANGED
@@ -4,6 +4,7 @@ import { AnalysisRegistry } from "./analysis-registry.js";
4
4
  import { buildTypedCelEnvironment, celEnvironment } from "./cel-environment.js";
5
5
  import { DefinitionRegistry } from "./definition-registry.js";
6
6
  import { buildDependencyGraph, formatCycle } from "./dependency-graph.js";
7
+ import { buildKernelGlobalsSchema, mergeKernelGlobalsIntoContext } from "./kernel-globals.js";
7
8
  import { normalizeInlineResources } from "./normalize-inline-resources.js";
8
9
  import {
9
10
  celTypeSatisfiesJsonSchema,
@@ -317,6 +318,10 @@ export class StaticAnalyzer {
317
318
  }
318
319
  }
319
320
 
321
+ // Build typed kernel globals schema so x-telo-context chain validation
322
+ // recognises variables, secrets, resources, env automatically
323
+ const kernelGlobals = buildKernelGlobalsSchema(allManifests);
324
+
320
325
  // Validate each non-definition, non-system resource
321
326
  for (const m of allManifests) {
322
327
  if (!m.kind || !m.metadata?.name) {
@@ -463,11 +468,12 @@ export class StaticAnalyzer {
463
468
  const manifestItem = matchedScope
464
469
  ? getManifestItem(path, matchedScope, m as Record<string, any>)
465
470
  : (m as Record<string, any>);
466
- const effectiveContext = resolveContextAnnotations(
471
+ const resolvedContext = resolveContextAnnotations(
467
472
  matchedContext,
468
473
  manifestItem,
469
474
  allManifests as Record<string, any>[],
470
475
  );
476
+ const effectiveContext = mergeKernelGlobalsIntoContext(resolvedContext, kernelGlobals);
471
477
 
472
478
  for (const chain of accessChains) {
473
479
  const err = validateChainAgainstSchema(chain, effectiveContext);
@@ -20,8 +20,11 @@ export const celEnvironment = new Environment({ unlistedVariablesAreDyn: true })
20
20
  *
21
21
  * - `variables`: typed from the manifest's `variables` field if it is a schema map
22
22
  * (only `Kernel.Module` resources carry this); otherwise registered as `map` (dyn).
23
- * - `secrets`, `resources`, `imports`, `env`: always `map` (dyn — output schemas unknown).
24
- * - `extraContextSchema`: additional variables from an `x-telo-context` annotation. */
23
+ * - `secrets`, `resources`, `env`: always `map` (dyn — output schemas unknown).
24
+ * - `extraContextSchema`: additional variables from an `x-telo-context` annotation.
25
+ *
26
+ * NOTE: The set of kernel globals registered here must match `KERNEL_GLOBAL_NAMES`
27
+ * in kernel-globals.ts, which is used for chain-access validation. */
25
28
  export function buildTypedCelEnvironment(
26
29
  manifest: ResourceManifest,
27
30
  extraContextSchema?: Record<string, any> | null,
@@ -50,7 +53,6 @@ export function buildTypedCelEnvironment(
50
53
 
51
54
  env.registerVariable("secrets", "map");
52
55
  env.registerVariable("resources", "map");
53
- env.registerVariable("imports", "map");
54
56
  env.registerVariable("env", "map");
55
57
 
56
58
  if (extraContextSchema?.properties) {
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ export { RegistryAdapter } from "./adapters/registry-adapter.js";
4
4
  export { AnalysisRegistry } from "./analysis-registry.js";
5
5
  export { StaticAnalyzer } from "./analyzer.js";
6
6
  export { Loader } from "./manifest-loader.js";
7
- export { DiagnosticSeverity } from "./types.js";
7
+ export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
8
8
  export type {
9
9
  AnalysisDiagnostic,
10
10
  AnalysisOptions, LoaderInitOptions, LoadOptions, ManifestAdapter,
@@ -0,0 +1,110 @@
1
+ import type { ResourceManifest } from "@telorun/sdk";
2
+
3
+ /**
4
+ * Kernel global names available in every CEL evaluation context at runtime.
5
+ * Both `buildKernelGlobalsSchema` (chain-access validation) and
6
+ * `buildTypedCelEnvironment` in cel-environment.ts (CEL type-checking)
7
+ * must stay in sync with this list.
8
+ *
9
+ * Note: `env` is only available in the root module context. Child modules
10
+ * loaded via Kernel.Import do not receive host environment variables.
11
+ * There is no `imports` namespace at runtime — import snapshots are stored
12
+ * under `resources.<alias>`.
13
+ */
14
+ export const KERNEL_GLOBAL_NAMES = ["variables", "secrets", "resources", "env"] as const;
15
+
16
+ const SYSTEM_KINDS = new Set([
17
+ "Kernel.Definition",
18
+ "Kernel.Module",
19
+ "Kernel.Abstract",
20
+ ]);
21
+
22
+ /**
23
+ * Build a typed JSON Schema describing the kernel globals available in the
24
+ * given manifest set. Used to merge into `x-telo-context` schemas so that
25
+ * chain-access validation recognises kernel globals without module authors
26
+ * having to re-declare them.
27
+ *
28
+ * - `variables` / `secrets`: typed from the `Kernel.Module` declaration
29
+ * - `resources`: enumerates all non-system resource names
30
+ * - `env`: dynamic (runtime env vars, root module only)
31
+ */
32
+ export function buildKernelGlobalsSchema(
33
+ manifests: ResourceManifest[],
34
+ ): Record<string, any> {
35
+ const moduleManifest = manifests.find((m) => m.kind === "Kernel.Module") as
36
+ | Record<string, any>
37
+ | undefined;
38
+
39
+ const resourceProps: Record<string, any> = {};
40
+ for (const m of manifests) {
41
+ const name = m.metadata?.name as string | undefined;
42
+ if (!name || !m.kind) continue;
43
+ // Kernel.Import snapshots are stored under resources.<alias> at runtime,
44
+ // so they appear here alongside regular resources.
45
+ if (!SYSTEM_KINDS.has(m.kind)) {
46
+ resourceProps[name] = { type: "object", additionalProperties: true };
47
+ }
48
+ }
49
+
50
+ return {
51
+ type: "object",
52
+ properties: {
53
+ variables: buildSchemaMapSchema(moduleManifest?.variables),
54
+ secrets: buildSchemaMapSchema(moduleManifest?.secrets),
55
+ resources: {
56
+ type: "object",
57
+ properties: resourceProps,
58
+ additionalProperties: false,
59
+ },
60
+ env: { type: "object", additionalProperties: true },
61
+ },
62
+ };
63
+ }
64
+
65
+ /** Wrap a JSON Schema property map (like `Kernel.Module.variables`) into a
66
+ * closed object schema suitable for chain-access validation. Falls back to
67
+ * an open map when the module declares no variables/secrets. */
68
+ function buildSchemaMapSchema(
69
+ schemaMap: Record<string, any> | null | undefined,
70
+ ): Record<string, any> {
71
+ if (!schemaMap || typeof schemaMap !== "object" || Array.isArray(schemaMap)) {
72
+ return { type: "object", additionalProperties: true };
73
+ }
74
+ const props: Record<string, any> = {};
75
+ for (const [key, value] of Object.entries(schemaMap)) {
76
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
77
+ props[key] = value;
78
+ }
79
+ }
80
+ if (Object.keys(props).length === 0) {
81
+ return { type: "object", additionalProperties: true };
82
+ }
83
+ return {
84
+ type: "object",
85
+ properties: props,
86
+ additionalProperties: false,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Merge kernel globals into an `x-telo-context` schema so chain-access
92
+ * validation recognises `variables`, `secrets`, `resources`, `env`
93
+ * without module authors having to re-declare them.
94
+ *
95
+ * Context-specific properties take precedence over globals (spread order).
96
+ * The original `additionalProperties` setting is preserved.
97
+ */
98
+ export function mergeKernelGlobalsIntoContext(
99
+ contextSchema: Record<string, any>,
100
+ globalsSchema: Record<string, any>,
101
+ ): Record<string, any> {
102
+ return {
103
+ ...contextSchema,
104
+ properties: {
105
+ ...globalsSchema.properties,
106
+ ...(contextSchema.properties ?? {}),
107
+ },
108
+ additionalProperties: contextSchema.additionalProperties ?? false,
109
+ };
110
+ }
@@ -1,4 +1,4 @@
1
- import type { ResourceManifest } from "@telorun/sdk";
1
+ import { isCompiledValue, type ResourceManifest } from "@telorun/sdk";
2
2
  import { isMap, isPair, isScalar, isSeq, parseAllDocuments, type Document } from "yaml";
3
3
  import { HttpAdapter } from "./adapters/http-adapter.js";
4
4
  import { RegistryAdapter } from "./adapters/registry-adapter.js";
@@ -12,6 +12,11 @@ import type {
12
12
  } from "./types.js";
13
13
 
14
14
  export class Loader {
15
+ private static readonly moduleCache = new Map<
16
+ string,
17
+ { text: string; manifests: ResourceManifest[] }
18
+ >();
19
+
15
20
  protected adapters: ManifestAdapter[];
16
21
 
17
22
  constructor(extraAdaptersOrOptions: ManifestAdapter[] | LoaderInitOptions = []) {
@@ -49,6 +54,12 @@ export class Loader {
49
54
 
50
55
  async loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
51
56
  const { text, source } = await this.pick(url).read(url);
57
+ const cacheKey = `${options?.compile ? "compiled" : "raw"}:${source}`;
58
+ const cached = Loader.moduleCache.get(cacheKey);
59
+ if (cached && cached.text === text) {
60
+ return cloneManifestArray(cached.manifests);
61
+ }
62
+
52
63
  const parsedDocuments = parseAllDocuments(text);
53
64
  const rawDocs = parsedDocuments.map((d) => d.toJSON());
54
65
  const offsets = documentLineOffsets(text);
@@ -117,7 +128,8 @@ export class Loader {
117
128
  }
118
129
  }
119
130
 
120
- return resolved;
131
+ Loader.moduleCache.set(cacheKey, { text, manifests: resolved });
132
+ return cloneManifestArray(resolved);
121
133
  }
122
134
 
123
135
  async loadModuleGraph(
@@ -215,6 +227,32 @@ export class Loader {
215
227
  }
216
228
  }
217
229
 
230
+ function cloneManifestArray(manifests: ResourceManifest[]): ResourceManifest[] {
231
+ return manifests.map((manifest) => cloneManifestValue(manifest));
232
+ }
233
+
234
+ function cloneManifestValue<T>(value: T): T {
235
+ if (Array.isArray(value)) {
236
+ return value.map((entry) => cloneManifestValue(entry)) as T;
237
+ }
238
+ if (isCompiledValue(value)) {
239
+ return value;
240
+ }
241
+ if (value !== null && typeof value === "object") {
242
+ const source = value as Record<string, unknown>;
243
+ const clone: Record<string, unknown> = {};
244
+ for (const [key, entry] of Object.entries(source)) {
245
+ clone[key] = cloneManifestValue(entry);
246
+ }
247
+ const positionIndex = Object.getOwnPropertyDescriptor(source, "positionIndex");
248
+ if (positionIndex) {
249
+ Object.defineProperty(clone, "positionIndex", positionIndex);
250
+ }
251
+ return clone as T;
252
+ }
253
+ return value;
254
+ }
255
+
218
256
  function documentLineOffsets(text: string): number[] {
219
257
  const offsets = [0];
220
258
  const lines = text.split("\n");
@@ -14,6 +14,7 @@ export function createAjv(): InstanceType<typeof Ajv> {
14
14
  }
15
15
 
16
16
  const ajv = createAjv();
17
+ const compiledSchemaValidators = new WeakMap<Record<string, any>, ReturnType<typeof ajv.compile>>();
17
18
 
18
19
  export interface CompatibilityResult {
19
20
  compatible: boolean;
@@ -131,11 +132,14 @@ export interface SchemaIssue {
131
132
 
132
133
  /** Validate actual data against a JSON Schema. Returns issues with path info, or empty array if valid. */
133
134
  export function validateAgainstSchema(data: unknown, schema: Record<string, any>): SchemaIssue[] {
134
- let validate: ReturnType<typeof ajv.compile>;
135
- try {
136
- validate = ajv.compile(schema);
137
- } catch {
138
- return [];
135
+ let validate = compiledSchemaValidators.get(schema);
136
+ if (!validate) {
137
+ try {
138
+ validate = ajv.compile(schema);
139
+ compiledSchemaValidators.set(schema, validate);
140
+ } catch {
141
+ return [];
142
+ }
139
143
  }
140
144
  if (validate(data)) return [];
141
145
  return (validate.errors ?? []).map((err: any) => ({
@@ -260,25 +264,55 @@ export function celPlaceholderForSchema(schema: Record<string, any>): unknown {
260
264
 
261
265
  const CEL_PURE_RE = /^\s*\$\{\{[^}]*\}\}\s*$/;
262
266
 
267
+ /** Resolve a `$ref` (only `#/$defs/...` form) against the root schema. */
268
+ function resolveRef(schema: Record<string, any>, root: Record<string, any>): Record<string, any> {
269
+ if (schema.$ref && typeof schema.$ref === "string" && schema.$ref.startsWith("#/$defs/")) {
270
+ const defName = schema.$ref.slice("#/$defs/".length);
271
+ const resolved = root.$defs?.[defName];
272
+ if (resolved) return resolved;
273
+ }
274
+ return schema;
275
+ }
276
+
277
+ /** Collect property schemas from top-level `properties` and all `oneOf`/`anyOf` sub-schemas. */
278
+ function collectProperties(schema: Record<string, any>): Record<string, any> {
279
+ const props: Record<string, any> = { ...(schema.properties ?? {}) };
280
+ for (const sub of schema.oneOf ?? schema.anyOf ?? []) {
281
+ if (sub && typeof sub === "object" && sub.properties) {
282
+ for (const [k, v] of Object.entries(sub.properties as Record<string, any>)) {
283
+ if (!(k in props)) props[k] = v;
284
+ }
285
+ }
286
+ }
287
+ return props;
288
+ }
289
+
263
290
  /** Deep-clone `data`, replacing every pure CEL template string (`${{ expr }}`) with a
264
291
  * schema-appropriate placeholder so AJV can validate non-CEL fields without false positives. */
265
- export function substituteCelFields(data: unknown, schema: Record<string, any>): unknown {
292
+ export function substituteCelFields(
293
+ data: unknown,
294
+ schema: Record<string, any>,
295
+ rootSchema?: Record<string, any>,
296
+ ): unknown {
297
+ const root = rootSchema ?? schema;
298
+ const resolved = resolveRef(schema, root);
299
+
266
300
  if (typeof data === "string" && CEL_PURE_RE.test(data)) {
267
- return celPlaceholderForSchema(schema);
301
+ return celPlaceholderForSchema(resolved);
268
302
  }
269
303
  if (Array.isArray(data)) {
270
- const itemSchema = (schema.items ?? {}) as Record<string, any>;
271
- return data.map((item) => substituteCelFields(item, itemSchema));
304
+ const itemSchema = resolveRef((resolved.items ?? {}) as Record<string, any>, root);
305
+ return data.map((item) => substituteCelFields(item, itemSchema, root));
272
306
  }
273
307
  if (data !== null && typeof data === "object") {
274
- const props = (schema.properties ?? {}) as Record<string, any>;
308
+ const props = collectProperties(resolved);
275
309
  const addlProps =
276
- schema.additionalProperties && typeof schema.additionalProperties === "object"
277
- ? (schema.additionalProperties as Record<string, any>)
310
+ resolved.additionalProperties && typeof resolved.additionalProperties === "object"
311
+ ? (resolved.additionalProperties as Record<string, any>)
278
312
  : undefined;
279
313
  const result: Record<string, unknown> = {};
280
314
  for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
281
- result[k] = substituteCelFields(v, (props[k] ?? addlProps ?? {}) as Record<string, any>);
315
+ result[k] = substituteCelFields(v, (props[k] ?? addlProps ?? {}) as Record<string, any>, root);
282
316
  }
283
317
  return result;
284
318
  }
package/src/types.ts CHANGED
@@ -8,6 +8,9 @@ export const DiagnosticSeverity = {
8
8
  } as const;
9
9
  export type DiagnosticSeverity = (typeof DiagnosticSeverity)[keyof typeof DiagnosticSeverity];
10
10
 
11
+ /** Default entry-point filename when a directory is given instead of a file. */
12
+ export const DEFAULT_MANIFEST_FILENAME = "telo.yaml";
13
+
11
14
  export interface Position {
12
15
  /** 0-based line number */
13
16
  line: number;
@@ -134,10 +134,22 @@ export function validateReferences(
134
134
  if (!val) continue;
135
135
 
136
136
  // Name-only reference (plain string) — look up by name to validate.
137
+ // Qualified references use "Kind.Name" format (e.g. "Http.Api.PaymentApi");
138
+ // extract the resource name from the last dot segment.
137
139
  if (typeof val === "string") {
140
+ const lastDot = val.lastIndexOf(".");
141
+ const refName = lastDot > 0 ? val.slice(lastDot + 1) : val;
142
+ const refKindPrefix = lastDot > 0 ? val.slice(0, lastDot) : undefined;
138
143
  const target =
139
- byName.get(val) ?? visibleScopeManifests.find((m) => m.metadata?.name === val);
144
+ byName.get(refName) ?? visibleScopeManifests.find((m) => m.metadata?.name === refName);
140
145
  if (!target) {
146
+ // Cross-module reference: "Alias.ResourceName" (single dot, bare alias prefix).
147
+ // The resource lives in the imported module's scope and can't be validated here.
148
+ // Multi-dot prefixes like "Alias.Kind.Name" are local resources with qualified
149
+ // kinds — those must be validated.
150
+ if (refKindPrefix && !refKindPrefix.includes(".") && aliases.hasAlias(refKindPrefix)) {
151
+ continue;
152
+ }
141
153
  diagnostics.push({
142
154
  severity: DiagnosticSeverity.Error,
143
155
  code: "UNRESOLVED_REFERENCE",