@telorun/analyzer 0.8.1 → 0.10.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 (54) hide show
  1. package/dist/analysis-registry.d.ts +5 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +7 -2
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +5 -5
  6. package/dist/definition-registry.d.ts +13 -1
  7. package/dist/definition-registry.d.ts.map +1 -1
  8. package/dist/definition-registry.js +58 -2
  9. package/dist/dependency-graph.d.ts +1 -1
  10. package/dist/dependency-graph.d.ts.map +1 -1
  11. package/dist/dependency-graph.js +8 -2
  12. package/dist/flatten-for-analyzer.d.ts +30 -0
  13. package/dist/flatten-for-analyzer.d.ts.map +1 -0
  14. package/dist/flatten-for-analyzer.js +119 -0
  15. package/dist/index.d.ts +6 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +3 -0
  18. package/dist/loaded-types.d.ts +81 -0
  19. package/dist/loaded-types.d.ts.map +1 -0
  20. package/dist/loaded-types.js +1 -0
  21. package/dist/manifest-loader.d.ts +30 -9
  22. package/dist/manifest-loader.d.ts.map +1 -1
  23. package/dist/manifest-loader.js +197 -417
  24. package/dist/normalize-inline-resources.d.ts +1 -1
  25. package/dist/normalize-inline-resources.d.ts.map +1 -1
  26. package/dist/normalize-inline-resources.js +7 -2
  27. package/dist/parse-loaded-file.d.ts +12 -0
  28. package/dist/parse-loaded-file.d.ts.map +1 -0
  29. package/dist/parse-loaded-file.js +50 -0
  30. package/dist/position-metadata.d.ts +27 -0
  31. package/dist/position-metadata.d.ts.map +1 -0
  32. package/dist/position-metadata.js +88 -0
  33. package/dist/reference-field-map.d.ts +5 -0
  34. package/dist/reference-field-map.d.ts.map +1 -1
  35. package/dist/reference-field-map.js +9 -0
  36. package/dist/types.d.ts +6 -0
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/validate-references.d.ts.map +1 -1
  39. package/dist/validate-references.js +68 -1
  40. package/package.json +3 -3
  41. package/src/analysis-registry.ts +11 -2
  42. package/src/analyzer.ts +17 -5
  43. package/src/definition-registry.ts +76 -3
  44. package/src/dependency-graph.ts +9 -1
  45. package/src/flatten-for-analyzer.ts +134 -0
  46. package/src/index.ts +18 -0
  47. package/src/loaded-types.ts +86 -0
  48. package/src/manifest-loader.ts +230 -459
  49. package/src/normalize-inline-resources.ts +8 -1
  50. package/src/parse-loaded-file.ts +70 -0
  51. package/src/position-metadata.ts +106 -0
  52. package/src/reference-field-map.ts +13 -0
  53. package/src/types.ts +6 -0
  54. package/src/validate-references.ts +74 -1
@@ -36,6 +36,7 @@ export function normalizeInlineResources(
36
36
  resources: ResourceManifest[],
37
37
  registry: DefinitionRegistry,
38
38
  aliases?: AliasResolver,
39
+ aliasesByModule?: Map<string, AliasResolver>,
39
40
  ): ResourceManifest[] {
40
41
  const result = [...resources];
41
42
 
@@ -48,7 +49,13 @@ export function normalizeInlineResources(
48
49
  let i = 0;
49
50
  while (i < queue.length) {
50
51
  const resource = queue[i++];
51
- const fieldMap = registry.getFieldMapForKind(resource.kind, aliases);
52
+ // When aliasesByModule is available, use the expanded map so inline refs
53
+ // hidden behind an x-telo-schema-from indirection (e.g. an encoder inside
54
+ // an HttpDispatch.Outcomes/$defs/Returns sub-schema) reach extraction.
55
+ const fieldMap =
56
+ aliases && aliasesByModule
57
+ ? registry.expandedFieldMapForResource(resource, aliases, aliasesByModule)
58
+ : registry.getFieldMapForKind(resource.kind, aliases);
52
59
  if (!fieldMap) continue;
53
60
 
54
61
  const parentName = resource.metadata.name as string;
@@ -0,0 +1,70 @@
1
+ import type { Environment } from "@marcbachmann/cel-js";
2
+ import type { ResourceManifest } from "@telorun/sdk";
3
+ import { defaultCustomTags } from "@telorun/templating";
4
+ import { parseAllDocuments } from "yaml";
5
+ import { buildCelEnvironment } from "./cel-environment.js";
6
+ import type { LoadedFile, ParseError } from "./loaded-types.js";
7
+ import { buildDocumentPositions } from "./position-metadata.js";
8
+ import { precompileDoc } from "./precompile.js";
9
+
10
+ export interface ParseOptions {
11
+ /** When true, runs `precompileDoc` per document and stamps compiled CEL
12
+ * on the manifests — same flag `LoadOptions.compile` carries today. */
13
+ compile?: boolean;
14
+ /** CEL environment for precompile. Defaults to `buildCelEnvironment()`. */
15
+ celEnv?: Environment;
16
+ }
17
+
18
+ /** Pure: text in, structured load result out. No I/O, no caches. */
19
+ export function parseLoadedFile(
20
+ source: string,
21
+ requestedUrl: string,
22
+ text: string,
23
+ options?: ParseOptions,
24
+ ): LoadedFile {
25
+ const documents = parseAllDocuments(text, { customTags: defaultCustomTags() });
26
+ const positions = buildDocumentPositions(text, documents);
27
+
28
+ const parseErrors: ParseError[] = [];
29
+ documents.forEach((doc, documentIndex) => {
30
+ for (const err of doc.errors) {
31
+ parseErrors.push({
32
+ documentIndex,
33
+ message: err.message,
34
+ });
35
+ }
36
+ });
37
+
38
+ const manifests: Array<ResourceManifest | null> = [];
39
+ let env: Environment | undefined;
40
+ for (const doc of documents) {
41
+ const raw = doc.toJSON();
42
+ if (raw === null || raw === undefined) {
43
+ manifests.push(null);
44
+ continue;
45
+ }
46
+ if (options?.compile) {
47
+ env ??= options.celEnv ?? buildCelEnvironment();
48
+ try {
49
+ const compiled = precompileDoc(raw, env);
50
+ manifests.push(compiled as ResourceManifest);
51
+ } catch (error) {
52
+ throw new Error(
53
+ `Failed to compile manifest in ${source}: ${error instanceof Error ? error.message : String(error)}`,
54
+ );
55
+ }
56
+ } else {
57
+ manifests.push(raw as ResourceManifest);
58
+ }
59
+ }
60
+
61
+ return {
62
+ source,
63
+ requestedUrl,
64
+ text,
65
+ documents,
66
+ manifests,
67
+ positions,
68
+ parseErrors,
69
+ };
70
+ }
@@ -0,0 +1,106 @@
1
+ import { isMap, isPair, isScalar, isSeq, type Document } from "yaml";
2
+ import type { Position, PositionIndex } from "./types.js";
3
+
4
+ /** Single source of truth for "given the source text of a multi-document YAML
5
+ * file, where does each document start, and what is the byte→(line,char)
6
+ * table for the file." Both the analyzer's `Loader` and editor frontends
7
+ * feed the same parsed `yaml.Document[]` through this so diagnostics
8
+ * resolved against `positionIndex` / `sourceLine` line up identically
9
+ * across hosts. */
10
+
11
+ /** Per-document position metadata used by `normalizeDiagnostic`'s fallback chain. */
12
+ export interface DocumentPosition {
13
+ sourceLine: number;
14
+ positionIndex: PositionIndex;
15
+ }
16
+
17
+ /** Builds DocumentPosition entries aligned to `parsedDocs[i]`. */
18
+ export function buildDocumentPositions(
19
+ text: string,
20
+ parsedDocs: Document[],
21
+ ): DocumentPosition[] {
22
+ const docOffsets = documentLineOffsets(text);
23
+ const lineOffsets = buildLineOffsets(text);
24
+ return parsedDocs.map((doc, i) => ({
25
+ sourceLine: docOffsets[i] ?? 0,
26
+ positionIndex: buildPositionIndex(doc, lineOffsets),
27
+ }));
28
+ }
29
+
30
+ /** Line numbers (0-indexed) where each YAML document in a multi-doc file
31
+ * starts. The first document is always at line 0; subsequent entries point
32
+ * to the line after each `---` directive. */
33
+ export function documentLineOffsets(text: string): number[] {
34
+ const offsets = [0];
35
+ const lines = text.split("\n");
36
+ for (let i = 0; i < lines.length; i++) {
37
+ const t = lines[i].trimEnd();
38
+ if (t === "---" || t.startsWith("--- ")) offsets.push(i + 1);
39
+ }
40
+ return offsets;
41
+ }
42
+
43
+ /** Byte-offset → start-of-line lookup table. Index `i` is the byte offset of
44
+ * the first character on line `i`. Used with `offsetToPosition` to turn a
45
+ * yaml-AST node range into Range coordinates. */
46
+ export function buildLineOffsets(text: string): number[] {
47
+ const offsets: number[] = [0];
48
+ for (let i = 0; i < text.length; i++) {
49
+ if (text[i] === "\n") offsets.push(i + 1);
50
+ }
51
+ return offsets;
52
+ }
53
+
54
+ function offsetToPosition(offset: number, lineOffsets: number[]): Position {
55
+ let lo = 0;
56
+ let hi = lineOffsets.length - 1;
57
+ while (lo < hi) {
58
+ const mid = (lo + hi + 1) >> 1;
59
+ if (lineOffsets[mid] <= offset) lo = mid;
60
+ else hi = mid - 1;
61
+ }
62
+ return { line: lo, character: offset - lineOffsets[lo] };
63
+ }
64
+
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"). */
67
+ export function buildPositionIndex(doc: Document, lineOffsets: number[]): PositionIndex {
68
+ const index: PositionIndex = new Map();
69
+
70
+ function recordNode(node: any, path: string): void {
71
+ if (!node || !node.range) return;
72
+ const [start, , end] = node.range as [number, number, number];
73
+ index.set(path, {
74
+ start: offsetToPosition(start, lineOffsets),
75
+ end: offsetToPosition(end, lineOffsets),
76
+ });
77
+ }
78
+
79
+ function walk(node: any, path: string): void {
80
+ if (isMap(node)) {
81
+ for (const pair of node.items) {
82
+ if (!isPair(pair)) continue;
83
+ const key = isScalar(pair.key) ? String(pair.key.value) : null;
84
+ if (key == null) continue;
85
+ const childPath = path ? `${path}.${key}` : key;
86
+ if (pair.value != null) {
87
+ recordNode(pair.value, childPath);
88
+ walk(pair.value, childPath);
89
+ }
90
+ }
91
+ } else if (isSeq(node)) {
92
+ for (let i = 0; i < node.items.length; i++) {
93
+ const item = node.items[i];
94
+ const childPath = `${path}[${i}]`;
95
+ recordNode(item, childPath);
96
+ walk(item, childPath);
97
+ }
98
+ }
99
+ }
100
+
101
+ if (doc.contents) {
102
+ walk(doc.contents, "");
103
+ }
104
+
105
+ return index;
106
+ }
@@ -135,6 +135,19 @@ function collectRefs(node: Record<string, any>): string[] {
135
135
  return refs;
136
136
  }
137
137
 
138
+ /** Traverses an arbitrary JSON Schema starting at the given path prefix. Used to
139
+ * expand x-telo-schema-from sub-schemas into nested ref/scope entries so Phase 2
140
+ * inline normalization and Phase 5 injection see slots that the local field map
141
+ * hid behind the schema-from indirection. */
142
+ export function buildFieldMapAtPath(
143
+ schema: Record<string, any>,
144
+ pathPrefix: string,
145
+ ): ReferenceFieldMap {
146
+ const map: ReferenceFieldMap = new Map();
147
+ traverseNode(schema, pathPrefix, map);
148
+ return map;
149
+ }
150
+
138
151
  function traverseNode(node: Record<string, any>, path: string, map: ReferenceFieldMap): void {
139
152
  // Scope slot — record and stop; do not recurse into scope contents
140
153
  if ("x-telo-scope" in node) {
package/src/types.ts CHANGED
@@ -92,4 +92,10 @@ export interface AnalysisOptions {
92
92
  export interface AnalysisContext {
93
93
  aliases?: import("./alias-resolver.js").AliasResolver;
94
94
  definitions?: import("./definition-registry.js").DefinitionRegistry;
95
+ /** Per-library alias resolvers keyed by the library's module name. Populated by
96
+ * the analyzer when imports are forwarded from inside imported libraries.
97
+ * Validators that resolve schema-side annotations (e.g. x-telo-schema-from
98
+ * pointing at an imported kind) consult the kind owner's scope here, since
99
+ * the consumer's aliases will not contain a library's private imports. */
100
+ aliasesByModule?: Map<string, import("./alias-resolver.js").AliasResolver>;
95
101
  }
@@ -73,6 +73,7 @@ export function validateReferences(
73
73
  const diagnostics: AnalysisDiagnostic[] = [];
74
74
  const aliases = context.aliases;
75
75
  const registry = context.definitions;
76
+ const aliasesByModule = context.aliasesByModule;
76
77
  if (!aliases || !registry) return diagnostics;
77
78
 
78
79
  // Build outer resource lookup by name for resolution check.
@@ -86,7 +87,12 @@ export function validateReferences(
86
87
  for (const r of resources) {
87
88
  if (!r.metadata?.name || !r.kind || SYSTEM_KINDS.has(r.kind)) continue;
88
89
 
89
- const fieldMap = registry.getFieldMapForKind(r.kind, aliases);
90
+ // Use the expanded map so refs nested behind x-telo-schema-from get the
91
+ // same kind-check / unresolved-name validation as locally-declared refs.
92
+ // Falls back to the base map when aliasesByModule isn't supplied.
93
+ const fieldMap = aliasesByModule
94
+ ? registry.expandedFieldMapForResource(r, aliases, aliasesByModule)
95
+ : registry.getFieldMapForKind(r.kind, aliases);
90
96
  if (!fieldMap) continue;
91
97
 
92
98
  const resourceLabel = `${r.kind}/${r.metadata.name as string}`;
@@ -260,6 +266,73 @@ export function validateReferences(
260
266
  const anchorName = expr.slice(0, slashIdx);
261
267
  const jsonPointer = "/" + expr.slice(slashIdx + 1);
262
268
 
269
+ // Aliased absolute kind path — first segment carries a dot, e.g.
270
+ // "HttpDispatch.Outcomes/$defs/Returns". Resolves the alias through the
271
+ // *kind owner's* scope (not the consumer's), navigates the JSON Pointer
272
+ // into the resolved definition's schema, and validates each field value.
273
+ //
274
+ // Relative anchors are property names that cannot contain a dot
275
+ // (CEL-style identifiers), so a dot in anchorName is unambiguous.
276
+ if (!isAbsolute && anchorName.includes(".")) {
277
+ const resolvedResourceKind = aliases.resolveKind(r.kind) ?? r.kind;
278
+ const resourceDef =
279
+ registry.resolve(r.kind) ?? registry.resolve(resolvedResourceKind);
280
+ const owningModule = (resourceDef?.metadata as { module?: string } | undefined)?.module;
281
+ const ownerScope =
282
+ (owningModule ? aliasesByModule?.get(owningModule) : undefined) ?? aliases;
283
+
284
+ const targetKind = ownerScope.resolveKind(anchorName);
285
+ if (!targetKind) {
286
+ diagnostics.push({
287
+ severity: DiagnosticSeverity.Error,
288
+ code: "SCHEMA_FROM_MISSING_PATH",
289
+ source: SOURCE,
290
+ message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → cannot resolve alias '${anchorName}'`,
291
+ data: { resource: resourceData, filePath, path: fieldPath },
292
+ });
293
+ continue;
294
+ }
295
+
296
+ const targetDef = registry.resolve(targetKind);
297
+ if (!targetDef?.schema) {
298
+ diagnostics.push({
299
+ severity: DiagnosticSeverity.Error,
300
+ code: "SCHEMA_FROM_MISSING_PATH",
301
+ source: SOURCE,
302
+ message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema`,
303
+ data: { resource: resourceData, filePath, path: fieldPath },
304
+ });
305
+ continue;
306
+ }
307
+
308
+ const subSchema = navigateJsonPointer(targetDef.schema, jsonPointer);
309
+ if (subSchema === undefined) {
310
+ diagnostics.push({
311
+ severity: DiagnosticSeverity.Error,
312
+ code: "SCHEMA_FROM_MISSING_PATH",
313
+ source: SOURCE,
314
+ message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema path '${jsonPointer}'`,
315
+ data: { resource: resourceData, filePath, path: fieldPath },
316
+ });
317
+ continue;
318
+ }
319
+
320
+ for (const fieldValue of resolveFieldValues(r, fieldPath)) {
321
+ if (fieldValue == null) continue;
322
+ const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
323
+ for (const issue of issues) {
324
+ diagnostics.push({
325
+ severity: DiagnosticSeverity.Error,
326
+ code: "DEPENDENT_SCHEMA_MISMATCH",
327
+ source: SOURCE,
328
+ message: `${resourceLabel}: '${fieldPath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
329
+ data: { resource: resourceData, filePath, path: fieldPath },
330
+ });
331
+ }
332
+ }
333
+ continue;
334
+ }
335
+
263
336
  // Derive the anchor path in the resource config.
264
337
  let anchorPath: string;
265
338
  if (isAbsolute) {