@telorun/analyzer 0.1.2 → 0.1.4

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 (47) 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 +3 -1
  6. package/dist/adapters/node-adapter.d.ts.map +1 -1
  7. package/dist/adapters/node-adapter.js +43 -4
  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/builtins.d.ts.map +1 -1
  14. package/dist/builtins.js +4 -0
  15. package/dist/cel-environment.d.ts +5 -2
  16. package/dist/cel-environment.d.ts.map +1 -1
  17. package/dist/cel-environment.js +5 -3
  18. package/dist/index.d.ts +1 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -2
  21. package/dist/kernel-globals.d.ts +34 -0
  22. package/dist/kernel-globals.d.ts.map +1 -0
  23. package/dist/kernel-globals.js +94 -0
  24. package/dist/manifest-loader.d.ts +10 -2
  25. package/dist/manifest-loader.d.ts.map +1 -1
  26. package/dist/manifest-loader.js +164 -0
  27. package/dist/schema-compat.d.ts +1 -1
  28. package/dist/schema-compat.d.ts.map +1 -1
  29. package/dist/schema-compat.js +43 -14
  30. package/dist/types.d.ts +10 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js +2 -0
  33. package/dist/validate-references.d.ts.map +1 -1
  34. package/dist/validate-references.js +13 -1
  35. package/package.json +22 -3
  36. package/src/adapters/http-adapter.ts +2 -2
  37. package/src/adapters/registry-adapter.ts +2 -2
  38. package/src/analyzer.ts +7 -1
  39. package/src/builtins.ts +4 -0
  40. package/src/cel-environment.ts +5 -3
  41. package/src/index.ts +1 -2
  42. package/src/kernel-globals.ts +110 -0
  43. package/src/manifest-loader.ts +204 -7
  44. package/src/schema-compat.ts +47 -13
  45. package/src/types.ts +13 -0
  46. package/src/validate-references.ts +13 -1
  47. package/src/adapters/node-adapter.ts +0 -38
@@ -1,17 +1,25 @@
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";
5
5
  import { precompileDoc } from "./precompile.js";
6
- import type {
7
- LoadOptions,
8
- LoaderInitOptions,
9
- ManifestAdapter,
10
- Position,
11
- PositionIndex,
6
+ import {
7
+ DEFAULT_MANIFEST_FILENAME,
8
+ type LoadOptions,
9
+ type LoaderInitOptions,
10
+ type ManifestAdapter,
11
+ type Position,
12
+ type PositionIndex,
12
13
  } from "./types.js";
13
14
 
15
+ const SYSTEM_KINDS = new Set(["Kernel.Module", "Kernel.Import", "Kernel.Definition"]);
16
+
14
17
  export class Loader {
18
+ private static readonly moduleCache = new Map<
19
+ string,
20
+ { text: string; manifests: ResourceManifest[] }
21
+ >();
22
+
15
23
  protected adapters: ManifestAdapter[];
16
24
 
17
25
  constructor(extraAdaptersOrOptions: ManifestAdapter[] | LoaderInitOptions = []) {
@@ -49,6 +57,12 @@ export class Loader {
49
57
 
50
58
  async loadModule(url: string, options?: LoadOptions): Promise<ResourceManifest[]> {
51
59
  const { text, source } = await this.pick(url).read(url);
60
+ const cacheKey = `${options?.compile ? "compiled" : "raw"}:${source}`;
61
+ const cached = Loader.moduleCache.get(cacheKey);
62
+ if (cached && cached.text === text) {
63
+ return cloneManifestArray(cached.manifests);
64
+ }
65
+
52
66
  const parsedDocuments = parseAllDocuments(text);
53
67
  const rawDocs = parsedDocuments.map((d) => d.toJSON());
54
68
  const offsets = documentLineOffsets(text);
@@ -117,9 +131,152 @@ export class Loader {
117
131
  }
118
132
  }
119
133
 
134
+ // Expand include directives — load partial files into the same module scope.
135
+ // Results with includes are NOT cached because partial file content is not
136
+ // tracked in the cache key — the cache would serve stale data if a partial changes.
137
+ let hasIncludes = false;
138
+ if (moduleManifest) {
139
+ const includePatterns = (moduleManifest as any).include as string[] | undefined;
140
+ if (includePatterns?.length) {
141
+ hasIncludes = true;
142
+ const adapter = this.pick(source);
143
+ const includedFiles = await this.resolveIncludes(source, includePatterns, adapter);
144
+ for (const includedUrl of includedFiles) {
145
+ const partialManifests = await this.loadPartialFile(includedUrl, moduleName, options);
146
+ resolved.push(...partialManifests);
147
+ }
148
+ }
149
+ }
150
+
151
+ if (!hasIncludes) {
152
+ Loader.moduleCache.set(cacheKey, { text, manifests: resolved });
153
+ }
154
+ return cloneManifestArray(resolved);
155
+ }
156
+
157
+ private async resolveIncludes(
158
+ ownerSource: string,
159
+ patterns: string[],
160
+ adapter: ManifestAdapter,
161
+ ): Promise<string[]> {
162
+ const hasGlobs = patterns.some((p) => /[*?{}\[\]]/.test(p));
163
+ if (hasGlobs) {
164
+ if (!adapter.expandGlob) {
165
+ throw new Error(
166
+ `Include patterns in '${ownerSource}' contain globs but the adapter for this source ` +
167
+ `does not support glob expansion. Use explicit file paths instead of patterns like: ` +
168
+ patterns.filter((p) => /[*?{}\[\]]/.test(p)).join(", "),
169
+ );
170
+ }
171
+ return adapter.expandGlob(ownerSource, patterns);
172
+ }
173
+ // Literal relative paths — deduplicate in case the same file appears under multiple patterns.
174
+ return [...new Set(patterns.map((p) => adapter.resolveRelative(ownerSource, p)))];
175
+ }
176
+
177
+ private async loadPartialFile(
178
+ url: string,
179
+ ownerModuleName: string | undefined,
180
+ options?: LoadOptions,
181
+ ): Promise<ResourceManifest[]> {
182
+ const { text, source } = await this.pick(url).read(url);
183
+
184
+ const parsedDocuments = parseAllDocuments(text);
185
+ const rawDocs = parsedDocuments.map((d) => d.toJSON());
186
+ const offsets = documentLineOffsets(text);
187
+ const lineOffsets = buildLineOffsets(text);
188
+ const resolved: ResourceManifest[] = [];
189
+ let docIdx = 0;
190
+
191
+ for (const rawDoc of rawDocs) {
192
+ const currentDocIdx = docIdx++;
193
+ const sourceLine = offsets[currentDocIdx] ?? 0;
194
+ const positionIndex = buildPositionIndex(parsedDocuments[currentDocIdx], lineOffsets);
195
+ if (rawDoc === null || rawDoc === undefined) continue;
196
+
197
+ const kind = rawDoc.kind as string | undefined;
198
+ if (kind && SYSTEM_KINDS.has(kind)) {
199
+ throw new Error(
200
+ `Included file '${source}' contains '${kind}' which is not allowed in partial files. ` +
201
+ `Only the owner telo.yaml may declare ${kind} resources.`,
202
+ );
203
+ }
204
+
205
+ let compiledDocs: unknown[];
206
+ if (options?.compile) {
207
+ try {
208
+ const result = precompileDoc(rawDoc);
209
+ compiledDocs = Array.isArray(result) ? result : [result];
210
+ } catch (error) {
211
+ throw new Error(
212
+ `Failed to compile manifest in ${source}: ${error instanceof Error ? error.message : String(error)}`,
213
+ );
214
+ }
215
+ } else {
216
+ compiledDocs = [rawDoc];
217
+ }
218
+
219
+ for (const doc of compiledDocs) {
220
+ if (doc === null || doc === undefined) continue;
221
+ const manifest = doc as ResourceManifest;
222
+ const metadata = {
223
+ ...manifest.metadata,
224
+ source,
225
+ sourceLine,
226
+ ...(ownerModuleName && !manifest.metadata?.module ? { module: ownerModuleName } : {}),
227
+ };
228
+ Object.defineProperty(metadata, "positionIndex", {
229
+ value: positionIndex,
230
+ enumerable: false,
231
+ writable: true,
232
+ configurable: true,
233
+ });
234
+ resolved.push({ ...manifest, metadata });
235
+ }
236
+ }
237
+
120
238
  return resolved;
121
239
  }
122
240
 
241
+ async loadModuleForFile(
242
+ fileUrl: string,
243
+ ): Promise<{
244
+ ownerUrl: string;
245
+ manifests: ResourceManifest[];
246
+ sourceManifests: Map<string, ResourceManifest[]>;
247
+ } | null> {
248
+ // Try loading as a regular module first (it might be a telo.yaml itself).
249
+ // Use loadManifests (not loadModule) so imported definitions are included —
250
+ // otherwise the analyzer won't know about kinds from Kernel.Import sources.
251
+ try {
252
+ const docs = await this.loadModule(fileUrl);
253
+ const hasModule = docs.some((d) => d.kind === "Kernel.Module");
254
+ if (hasModule) {
255
+ const { source } = await this.pick(fileUrl).read(fileUrl);
256
+ const manifests = await this.loadManifests(fileUrl);
257
+ return { ownerUrl: source, manifests, sourceManifests: groupBySource(manifests) };
258
+ }
259
+ } catch (err) {
260
+ // If the file looks like an owner manifest (named telo.yaml), rethrow —
261
+ // a broken owner shouldn't silently fall through to parent lookup.
262
+ const normalized = fileUrl.replace(/\\/g, "/");
263
+ if (normalized.endsWith(`/${DEFAULT_MANIFEST_FILENAME}`) || normalized === DEFAULT_MANIFEST_FILENAME) {
264
+ throw err;
265
+ }
266
+ // Otherwise fall through to owner lookup — this is likely a partial file
267
+ }
268
+
269
+ // Find the owning telo.yaml via parent-directory traversal
270
+ const adapter = this.pick(fileUrl);
271
+ if (!adapter.resolveOwnerOf) return null;
272
+ const ownerUrl = await adapter.resolveOwnerOf(fileUrl);
273
+ if (!ownerUrl) return null;
274
+
275
+ // Load the owner module (which will load included files via include expansion)
276
+ const manifests = await this.loadManifests(ownerUrl);
277
+ return { ownerUrl, manifests, sourceManifests: groupBySource(manifests) };
278
+ }
279
+
123
280
  async loadModuleGraph(
124
281
  entryUrl: string,
125
282
  onError?: (url: string, error: Error) => void,
@@ -215,6 +372,46 @@ export class Loader {
215
372
  }
216
373
  }
217
374
 
375
+ function cloneManifestArray(manifests: ResourceManifest[]): ResourceManifest[] {
376
+ return manifests.map((manifest) => cloneManifestValue(manifest));
377
+ }
378
+
379
+ function cloneManifestValue<T>(value: T): T {
380
+ if (Array.isArray(value)) {
381
+ return value.map((entry) => cloneManifestValue(entry)) as T;
382
+ }
383
+ if (isCompiledValue(value)) {
384
+ return value;
385
+ }
386
+ if (value !== null && typeof value === "object") {
387
+ const source = value as Record<string, unknown>;
388
+ const clone: Record<string, unknown> = {};
389
+ for (const [key, entry] of Object.entries(source)) {
390
+ clone[key] = cloneManifestValue(entry);
391
+ }
392
+ const positionIndex = Object.getOwnPropertyDescriptor(source, "positionIndex");
393
+ if (positionIndex) {
394
+ Object.defineProperty(clone, "positionIndex", positionIndex);
395
+ }
396
+ return clone as T;
397
+ }
398
+ return value;
399
+ }
400
+
401
+ function groupBySource(manifests: ResourceManifest[]): Map<string, ResourceManifest[]> {
402
+ const map = new Map<string, ResourceManifest[]>();
403
+ for (const m of manifests) {
404
+ const src = (m.metadata?.source as string) ?? "unknown";
405
+ let list = map.get(src);
406
+ if (!list) {
407
+ list = [];
408
+ map.set(src, list);
409
+ }
410
+ list.push(m);
411
+ }
412
+ return map;
413
+ }
414
+
218
415
  function documentLineOffsets(text: string): number[] {
219
416
  const offsets = [0];
220
417
  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;
@@ -42,6 +45,16 @@ export interface ManifestAdapter {
42
45
  supports(url: string): boolean;
43
46
  read(url: string): Promise<{ text: string; source: string }>;
44
47
  resolveRelative(base: string, relative: string): string;
48
+
49
+ /** Expand glob patterns relative to a base source. Returns sources in the same
50
+ * format as read().source — suitable to pass back into read() / resolveRelative().
51
+ * Optional — only filesystem-capable adapters implement this. */
52
+ expandGlob?(base: string, patterns: string[]): Promise<string[]>;
53
+
54
+ /** Walk parent directories from fileUrl looking for the nearest telo.yaml.
55
+ * Returns the source in the same format as read().source, or null if none found.
56
+ * Optional — only filesystem-capable adapters implement this. */
57
+ resolveOwnerOf?(fileUrl: string): Promise<string | null>;
45
58
  }
46
59
 
47
60
  export interface LoadOptions {
@@ -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",
@@ -1,38 +0,0 @@
1
- import * as fs from "fs/promises";
2
- import * as path from "path";
3
- import type { ManifestAdapter } from "../types.js";
4
-
5
- /** Node.js fs-based ManifestAdapter for local files. Not browser-compatible. */
6
- export class NodeAdapter implements ManifestAdapter {
7
- constructor(private readonly cwd: string = process.cwd()) {}
8
-
9
- supports(url: string): boolean {
10
- return (
11
- url.startsWith("file://") ||
12
- url.startsWith("/") ||
13
- url.startsWith("./") ||
14
- url.startsWith("../") ||
15
- (!url.includes("://") && !url.includes("@"))
16
- );
17
- }
18
-
19
- async read(url: string): Promise<{ text: string; source: string }> {
20
- const filePath = url.startsWith("file://") ? new URL(url).pathname : url;
21
- const stat = await fs.stat(filePath).catch(() => null);
22
- const resolvedPath =
23
- stat?.isDirectory() ? path.join(filePath, "module.yaml") : filePath;
24
- const text = await fs.readFile(resolvedPath, "utf8");
25
- return { text, source: resolvedPath };
26
- }
27
-
28
- resolveRelative(base: string, relative: string): string {
29
- const basePath = base.startsWith("file://") ? new URL(base).pathname : base;
30
- const baseDir = path.dirname(path.resolve(this.cwd, basePath));
31
- return path.resolve(baseDir, relative);
32
- }
33
- }
34
-
35
- /** @deprecated Use `new NodeAdapter(cwd)` instead */
36
- export function createNodeAdapter(cwd: string = process.cwd()): ManifestAdapter {
37
- return new NodeAdapter(cwd);
38
- }