@telorun/analyzer 0.8.0 → 0.9.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 (38) hide show
  1. package/dist/analysis-registry.d.ts +1 -0
  2. package/dist/analysis-registry.d.ts.map +1 -1
  3. package/dist/analysis-registry.js +2 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +23 -21
  6. package/dist/flatten-for-analyzer.d.ts +30 -0
  7. package/dist/flatten-for-analyzer.d.ts.map +1 -0
  8. package/dist/flatten-for-analyzer.js +119 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +3 -0
  12. package/dist/loaded-types.d.ts +81 -0
  13. package/dist/loaded-types.d.ts.map +1 -0
  14. package/dist/loaded-types.js +1 -0
  15. package/dist/manifest-loader.d.ts +30 -9
  16. package/dist/manifest-loader.d.ts.map +1 -1
  17. package/dist/manifest-loader.js +197 -417
  18. package/dist/parse-loaded-file.d.ts +12 -0
  19. package/dist/parse-loaded-file.d.ts.map +1 -0
  20. package/dist/parse-loaded-file.js +50 -0
  21. package/dist/position-metadata.d.ts +27 -0
  22. package/dist/position-metadata.d.ts.map +1 -0
  23. package/dist/position-metadata.js +88 -0
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/validate-references.d.ts.map +1 -1
  27. package/dist/validate-references.js +62 -0
  28. package/package.json +3 -3
  29. package/src/analysis-registry.ts +2 -1
  30. package/src/analyzer.ts +33 -29
  31. package/src/flatten-for-analyzer.ts +134 -0
  32. package/src/index.ts +18 -0
  33. package/src/loaded-types.ts +86 -0
  34. package/src/manifest-loader.ts +230 -459
  35. package/src/parse-loaded-file.ts +70 -0
  36. package/src/position-metadata.ts +106 -0
  37. package/src/types.ts +6 -0
  38. package/src/validate-references.ts +68 -0
@@ -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
+ }
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.
@@ -260,6 +261,73 @@ export function validateReferences(
260
261
  const anchorName = expr.slice(0, slashIdx);
261
262
  const jsonPointer = "/" + expr.slice(slashIdx + 1);
262
263
 
264
+ // Aliased absolute kind path — first segment carries a dot, e.g.
265
+ // "HttpDispatch.Outcomes/$defs/Returns". Resolves the alias through the
266
+ // *kind owner's* scope (not the consumer's), navigates the JSON Pointer
267
+ // into the resolved definition's schema, and validates each field value.
268
+ //
269
+ // Relative anchors are property names that cannot contain a dot
270
+ // (CEL-style identifiers), so a dot in anchorName is unambiguous.
271
+ if (!isAbsolute && anchorName.includes(".")) {
272
+ const resolvedResourceKind = aliases.resolveKind(r.kind) ?? r.kind;
273
+ const resourceDef =
274
+ registry.resolve(r.kind) ?? registry.resolve(resolvedResourceKind);
275
+ const owningModule = (resourceDef?.metadata as { module?: string } | undefined)?.module;
276
+ const ownerScope =
277
+ (owningModule ? aliasesByModule?.get(owningModule) : undefined) ?? aliases;
278
+
279
+ const targetKind = ownerScope.resolveKind(anchorName);
280
+ if (!targetKind) {
281
+ diagnostics.push({
282
+ severity: DiagnosticSeverity.Error,
283
+ code: "SCHEMA_FROM_MISSING_PATH",
284
+ source: SOURCE,
285
+ message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → cannot resolve alias '${anchorName}'`,
286
+ data: { resource: resourceData, filePath, path: fieldPath },
287
+ });
288
+ continue;
289
+ }
290
+
291
+ const targetDef = registry.resolve(targetKind);
292
+ if (!targetDef?.schema) {
293
+ diagnostics.push({
294
+ severity: DiagnosticSeverity.Error,
295
+ code: "SCHEMA_FROM_MISSING_PATH",
296
+ source: SOURCE,
297
+ message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema`,
298
+ data: { resource: resourceData, filePath, path: fieldPath },
299
+ });
300
+ continue;
301
+ }
302
+
303
+ const subSchema = navigateJsonPointer(targetDef.schema, jsonPointer);
304
+ if (subSchema === undefined) {
305
+ diagnostics.push({
306
+ severity: DiagnosticSeverity.Error,
307
+ code: "SCHEMA_FROM_MISSING_PATH",
308
+ source: SOURCE,
309
+ message: `${resourceLabel}: x-telo-schema-from at '${fieldPath}' → kind '${targetKind}' has no schema path '${jsonPointer}'`,
310
+ data: { resource: resourceData, filePath, path: fieldPath },
311
+ });
312
+ continue;
313
+ }
314
+
315
+ for (const fieldValue of resolveFieldValues(r, fieldPath)) {
316
+ if (fieldValue == null) continue;
317
+ const issues = registry.validateWithRefs(fieldValue, subSchema as Record<string, any>);
318
+ for (const issue of issues) {
319
+ diagnostics.push({
320
+ severity: DiagnosticSeverity.Error,
321
+ code: "DEPENDENT_SCHEMA_MISMATCH",
322
+ source: SOURCE,
323
+ message: `${resourceLabel}: '${fieldPath}' does not match schema from '${anchorName}${jsonPointer}': ${issue}`,
324
+ data: { resource: resourceData, filePath, path: fieldPath },
325
+ });
326
+ }
327
+ }
328
+ continue;
329
+ }
330
+
263
331
  // Derive the anchor path in the resource config.
264
332
  let anchorPath: string;
265
333
  if (isAbsolute) {