@telorun/analyzer 0.39.0 → 0.41.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.
@@ -1,4 +1,3 @@
1
- import { isMap, isPair, isScalar, isSeq } from "yaml";
2
1
  /** Builds DocumentPosition entries aligned to `parsedDocs[i]`. */
3
2
  export function buildDocumentPositions(text, parsedDocs) {
4
3
  const docOffsets = documentLineOffsets(text);
@@ -40,7 +39,7 @@ export function buildLineOffsets(text) {
40
39
  }
41
40
  return offsets;
42
41
  }
43
- function offsetToPosition(offset, lineOffsets) {
42
+ export function offsetToPosition(offset, lineOffsets) {
44
43
  let lo = 0;
45
44
  let hi = lineOffsets.length - 1;
46
45
  while (lo < hi) {
@@ -52,7 +51,7 @@ function offsetToPosition(offset, lineOffsets) {
52
51
  }
53
52
  return { line: lo, character: offset - lineOffsets[lo] };
54
53
  }
55
- /** Walks the YAML AST and records source ranges for every field value, keyed
54
+ /** Walks the AST and records source ranges for every field value, keyed
56
55
  * by dotted path (e.g. "kind", "config.handler", "config.routes[0].path").
57
56
  * Map keys are also recorded under the `@key:<path>` namespace so diagnostic
58
57
  * resolvers can squiggle just the key identifier instead of the full value
@@ -61,33 +60,27 @@ function offsetToPosition(offset, lineOffsets) {
61
60
  export function buildPositionIndex(doc, lineOffsets) {
62
61
  const index = new Map();
63
62
  function recordNode(node, path) {
64
- if (!node || !node.range)
65
- return;
66
- const [start, , end] = node.range;
63
+ const [start, end] = node.range;
67
64
  index.set(path, {
68
65
  start: offsetToPosition(start, lineOffsets),
69
66
  end: offsetToPosition(end, lineOffsets),
70
67
  });
71
68
  }
72
69
  function walk(node, path) {
73
- if (isMap(node)) {
74
- for (const pair of node.items) {
75
- if (!isPair(pair))
76
- continue;
77
- const key = isScalar(pair.key) ? String(pair.key.value) : null;
70
+ if (node.kind === "map") {
71
+ for (const pair of node.entries) {
72
+ const key = pair.key.kind === "scalar" ? String(pair.key.value) : null;
78
73
  if (key == null)
79
74
  continue;
80
75
  const childPath = path ? `${path}.${key}` : key;
81
- if (pair.key && pair.key.range) {
82
- recordNode(pair.key, `@key:${childPath}`);
83
- }
84
- if (pair.value != null) {
76
+ recordNode(pair.key, `@key:${childPath}`);
77
+ if (pair.value) {
85
78
  recordNode(pair.value, childPath);
86
79
  walk(pair.value, childPath);
87
80
  }
88
81
  }
89
82
  }
90
- else if (isSeq(node)) {
83
+ else if (node.kind === "seq") {
91
84
  for (let i = 0; i < node.items.length; i++) {
92
85
  const item = node.items[i];
93
86
  const childPath = `${path}[${i}]`;
@@ -96,8 +89,8 @@ export function buildPositionIndex(doc, lineOffsets) {
96
89
  }
97
90
  }
98
91
  }
99
- if (doc.contents) {
100
- walk(doc.contents, "");
92
+ if (doc.root) {
93
+ walk(doc.root, "");
101
94
  }
102
95
  return index;
103
96
  }
@@ -0,0 +1,4 @@
1
+ /** True when `source` names an on-disk sibling manifest — a relative (`./`,
2
+ * `../`) or absolute (`/`) path — rather than a transport-owned remote ref. */
3
+ export declare function isLocalPathSource(source: string): boolean;
4
+ //# sourceMappingURL=local-path-ref.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-path-ref.d.ts","sourceRoot":"","sources":["../../src/sources/local-path-ref.ts"],"names":[],"mappings":"AAAA;gFACgF;AAChF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD"}
@@ -0,0 +1,5 @@
1
+ /** True when `source` names an on-disk sibling manifest — a relative (`./`,
2
+ * `../`) or absolute (`/`) path — rather than a transport-owned remote ref. */
3
+ export function isLocalPathSource(source) {
4
+ return source.startsWith(".") || source.startsWith("/");
5
+ }
@@ -0,0 +1,46 @@
1
+ import { type Document } from "yaml";
2
+ import { type CelSegment } from "./cel-ast.js";
3
+ /** Read-only YAML node model owned by the analyzer — the shared, browser-safe
4
+ * structural source of truth for every IDE feature. Ranges are `[start, end]`
5
+ * in byte offsets (yaml's value-end, so a range spans exactly the node's own
6
+ * text, not the trailing newline). `yaml` is an internal implementation
7
+ * detail behind `parseToAst`; no consumer imports it to read structure. */
8
+ export type AstNode = AstMap | AstSeq | AstScalar;
9
+ export interface AstMap {
10
+ kind: "map";
11
+ range: [number, number];
12
+ entries: AstPair[];
13
+ }
14
+ export interface AstSeq {
15
+ kind: "seq";
16
+ range: [number, number];
17
+ items: AstNode[];
18
+ }
19
+ export interface AstScalar {
20
+ kind: "scalar";
21
+ range: [number, number];
22
+ /** Resolved scalar value — a `TaggedSentinel` for `!cel` / `!ref` scalars. */
23
+ value: unknown;
24
+ /** The scalar's tag when present (`!cel`, `!ref`, …). */
25
+ tag?: string;
26
+ /** The embedded CEL regions (lazy — nothing parses CEL until called). */
27
+ celSegments(): CelSegment[];
28
+ }
29
+ export interface AstPair {
30
+ key: AstNode;
31
+ value?: AstNode;
32
+ }
33
+ export interface AstDocument {
34
+ root?: AstNode;
35
+ /** Full document span `[start, end]` — used to select the `---` document a
36
+ * cursor offset falls in. */
37
+ range: [number, number];
38
+ }
39
+ /** Parse `text` into the read-only AST. Wraps `parseAllDocuments` with the
40
+ * repo's custom tags (`!cel` / `!ref`) and adapts each `yaml` tree into a
41
+ * thin `AstNode` view — CEL parsing stays deferred to `celSegments().ast()`. */
42
+ export declare function parseToAst(text: string): AstDocument[];
43
+ /** Adapt one already-parsed `yaml.Document` into an `AstDocument`. Lets a host
44
+ * that already parsed for analysis reuse that parse instead of re-parsing. */
45
+ export declare function documentToAst(doc: Document, text: string): AstDocument;
46
+ //# sourceMappingURL=yaml-ast.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"yaml-ast.d.ts","sourceRoot":"","sources":["../src/yaml-ast.ts"],"names":[],"mappings":"AACA,OAAO,EAA6C,KAAK,QAAQ,EAAa,MAAM,MAAM,CAAC;AAC3F,OAAO,EAAoB,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAEjE;;;;4EAI4E;AAC5E,MAAM,MAAM,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAElD,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,KAAK,CAAC;IACZ,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxB,OAAO,EAAE,OAAO,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,KAAK,CAAC;IACZ,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxB,KAAK,EAAE,OAAO,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACxB,8EAA8E;IAC9E,KAAK,EAAE,OAAO,CAAC;IACf,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,WAAW,IAAI,UAAU,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;kCAC8B;IAC9B,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACzB;AAED;;iFAEiF;AACjF,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CAGtD;AAED;+EAC+E;AAC/E,wBAAgB,aAAa,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,CAMtE"}
@@ -0,0 +1,58 @@
1
+ import { defaultCustomTags, isTaggedSentinel } from "@telorun/templating";
2
+ import { isMap, isScalar, isSeq, parseAllDocuments } from "yaml";
3
+ import { buildCelSegments } from "./cel-ast.js";
4
+ /** Parse `text` into the read-only AST. Wraps `parseAllDocuments` with the
5
+ * repo's custom tags (`!cel` / `!ref`) and adapts each `yaml` tree into a
6
+ * thin `AstNode` view — CEL parsing stays deferred to `celSegments().ast()`. */
7
+ export function parseToAst(text) {
8
+ const documents = parseAllDocuments(text, { customTags: defaultCustomTags() });
9
+ return documents.map((doc) => documentToAst(doc, text));
10
+ }
11
+ /** Adapt one already-parsed `yaml.Document` into an `AstDocument`. Lets a host
12
+ * that already parsed for analysis reuse that parse instead of re-parsing. */
13
+ export function documentToAst(doc, text) {
14
+ const r = doc.range;
15
+ return {
16
+ root: doc.contents ? adaptNode(doc.contents, text) : undefined,
17
+ range: r ? [r[0], r[2]] : [0, text.length],
18
+ };
19
+ }
20
+ function nodeRange(node) {
21
+ const r = node.range;
22
+ return r ? [r[0], r[1]] : [0, 0];
23
+ }
24
+ function adaptNode(node, text) {
25
+ if (isMap(node)) {
26
+ const entries = [];
27
+ for (const item of node.items) {
28
+ const key = adaptNode(item.key, text);
29
+ if (!key)
30
+ continue;
31
+ const value = item.value != null ? adaptNode(item.value, text) : undefined;
32
+ entries.push({ key, value });
33
+ }
34
+ return { kind: "map", range: nodeRange(node), entries };
35
+ }
36
+ if (isSeq(node)) {
37
+ const items = [];
38
+ for (const item of node.items) {
39
+ const adapted = adaptNode(item, text);
40
+ if (adapted)
41
+ items.push(adapted);
42
+ }
43
+ return { kind: "seq", range: nodeRange(node), items };
44
+ }
45
+ if (isScalar(node)) {
46
+ const range = nodeRange(node);
47
+ const value = node.value;
48
+ const tag = typeof node.tag === "string" ? node.tag : undefined;
49
+ return {
50
+ kind: "scalar",
51
+ range,
52
+ value,
53
+ tag,
54
+ celSegments: () => buildCelSegments(text.slice(range[0], range[1]), range[0], tag, isTaggedSentinel(value) ? value.source : undefined),
55
+ };
56
+ }
57
+ return undefined;
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/analyzer",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "description": "Telo Analyzer - Static manifest validator for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
package/src/cel-ast.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { parse, type ASTNode as CelJsNode } from "@marcbachmann/cel-js";
2
+
3
+ /** Read-only CEL expression tree owned by the analyzer. The third-party
4
+ * `@marcbachmann/cel-js` `ASTNode` stays an internal detail — `wrapCelAst`
5
+ * translates it into this union so no external AST type leaks through the
6
+ * public surface (full symmetry with the YAML `AstNode` decision). Every
7
+ * `range` is `[start, end]` in DOCUMENT offsets. */
8
+ export type CelNode =
9
+ | { kind: "literal"; range: [number, number]; value: unknown }
10
+ | { kind: "ident"; range: [number, number]; name: string }
11
+ | {
12
+ kind: "member";
13
+ range: [number, number];
14
+ target: CelNode;
15
+ property: string;
16
+ /** Span of just the `.prop` identifier, for a future rename. */
17
+ propertyRange: [number, number];
18
+ /** `.?` optional member access. */
19
+ optional: boolean;
20
+ }
21
+ | {
22
+ kind: "index";
23
+ range: [number, number];
24
+ target: CelNode;
25
+ index: CelNode;
26
+ /** `[?]` optional index. */
27
+ optional: boolean;
28
+ }
29
+ | { kind: "call"; range: [number, number]; name: string; args: CelNode[] }
30
+ | {
31
+ kind: "methodCall";
32
+ range: [number, number];
33
+ name: string;
34
+ receiver: CelNode;
35
+ args: CelNode[];
36
+ }
37
+ | { kind: "list"; range: [number, number]; items: CelNode[] }
38
+ | { kind: "map"; range: [number, number]; entries: { key: CelNode; value: CelNode }[] }
39
+ | {
40
+ kind: "ternary";
41
+ range: [number, number];
42
+ cond: CelNode;
43
+ then: CelNode;
44
+ else: CelNode;
45
+ }
46
+ | { kind: "unary"; range: [number, number]; op: string; operand: CelNode }
47
+ | { kind: "binary"; range: [number, number]; op: string; left: CelNode; right: CelNode };
48
+
49
+ /** A `${{ }}` / `!cel` region inside a YAML scalar. Ranges are DOCUMENT
50
+ * offsets; `source` is the CEL body (a longest-valid prefix when `open`).
51
+ * `ast()` parses lazily — nothing parses CEL during `parseToAst`, only the
52
+ * expression a caller actually inspects. */
53
+ export interface CelSegment {
54
+ /** Segment span in document offsets (includes the `${{ }}` for interpolation). */
55
+ range: [number, number];
56
+ /** The CEL body (a prefix when `open`). */
57
+ source: string;
58
+ /** True when a `${{` has no matching `}}` yet (the user is mid-typing). */
59
+ open: boolean;
60
+ /** Lazily parse + wrap; ranges are already absolute. */
61
+ ast(): CelNode;
62
+ }
63
+
64
+ const BINARY_OPS = new Set([
65
+ "!=",
66
+ "==",
67
+ "in",
68
+ "+",
69
+ "-",
70
+ "*",
71
+ "/",
72
+ "%",
73
+ "<",
74
+ "<=",
75
+ ">",
76
+ ">=",
77
+ "||",
78
+ "&&",
79
+ ]);
80
+
81
+ /** Maps a `@marcbachmann/cel-js` node into the analyzer `CelNode`, translating
82
+ * each node's segment-relative `start`/`end` to absolute document offsets by
83
+ * adding `segmentStart`. */
84
+ export function wrapCelAst(node: CelJsNode, segmentStart: number): CelNode {
85
+ const range = abs(node, segmentStart);
86
+ const op = node.op;
87
+ const args = node.args as unknown;
88
+
89
+ if (op === "value") return { kind: "literal", range, value: args };
90
+ if (op === "id") return { kind: "ident", range, name: String(args) };
91
+ if (op === "." || op === ".?") {
92
+ const [target, property] = args as [CelJsNode, string];
93
+ return {
94
+ kind: "member",
95
+ range,
96
+ target: wrapCelAst(target, segmentStart),
97
+ property,
98
+ propertyRange: [range[1] - property.length, range[1]],
99
+ optional: op === ".?",
100
+ };
101
+ }
102
+ if (op === "[]" || op === "[?]") {
103
+ const [target, index] = args as [CelJsNode, CelJsNode];
104
+ return {
105
+ kind: "index",
106
+ range,
107
+ target: wrapCelAst(target, segmentStart),
108
+ index: wrapCelAst(index, segmentStart),
109
+ optional: op === "[?]",
110
+ };
111
+ }
112
+ if (op === "call") {
113
+ const [name, callArgs] = args as [string, CelJsNode[]];
114
+ return { kind: "call", range, name, args: callArgs.map((a) => wrapCelAst(a, segmentStart)) };
115
+ }
116
+ if (op === "rcall") {
117
+ const [name, receiver, callArgs] = args as [string, CelJsNode, CelJsNode[]];
118
+ return {
119
+ kind: "methodCall",
120
+ range,
121
+ name,
122
+ receiver: wrapCelAst(receiver, segmentStart),
123
+ args: callArgs.map((a) => wrapCelAst(a, segmentStart)),
124
+ };
125
+ }
126
+ if (op === "list") {
127
+ return { kind: "list", range, items: (args as CelJsNode[]).map((a) => wrapCelAst(a, segmentStart)) };
128
+ }
129
+ if (op === "map") {
130
+ return {
131
+ kind: "map",
132
+ range,
133
+ entries: (args as [CelJsNode, CelJsNode][]).map(([k, v]) => ({
134
+ key: wrapCelAst(k, segmentStart),
135
+ value: wrapCelAst(v, segmentStart),
136
+ })),
137
+ };
138
+ }
139
+ if (op === "?:") {
140
+ const [cond, then, els] = args as [CelJsNode, CelJsNode, CelJsNode];
141
+ return {
142
+ kind: "ternary",
143
+ range,
144
+ cond: wrapCelAst(cond, segmentStart),
145
+ then: wrapCelAst(then, segmentStart),
146
+ else: wrapCelAst(els, segmentStart),
147
+ };
148
+ }
149
+ if (op === "!_" || op === "-_") {
150
+ return { kind: "unary", range, op, operand: wrapCelAst(args as CelJsNode, segmentStart) };
151
+ }
152
+ if (BINARY_OPS.has(op)) {
153
+ const [left, right] = args as [CelJsNode, CelJsNode];
154
+ return {
155
+ kind: "binary",
156
+ range,
157
+ op,
158
+ left: wrapCelAst(left, segmentStart),
159
+ right: wrapCelAst(right, segmentStart),
160
+ };
161
+ }
162
+ // Unknown operator — surface it as a literal so consumers can still hit-test
163
+ // the range rather than crash on an unmapped node.
164
+ return { kind: "literal", range, value: undefined };
165
+ }
166
+
167
+ function abs(node: CelJsNode, segmentStart: number): [number, number] {
168
+ const r = node.range ?? { start: node.start, end: node.end };
169
+ return [r.start + segmentStart, r.end + segmentStart];
170
+ }
171
+
172
+ /** Parse `source` and wrap it, tolerating a trailing partial member/index
173
+ * access (`req.`, `req.fo`) by falling back to the longest parseable prefix.
174
+ * Used for `open` segments where completion fires mid-token. */
175
+ function parseLenient(source: string, segmentStart: number, range: [number, number]): CelNode {
176
+ const candidates = [source, source.replace(/[.?[]+\w*$/, ""), source.replace(/[.?[(]+.*$/, "")];
177
+ for (const candidate of candidates) {
178
+ const trimmed = candidate.trim();
179
+ if (!trimmed) break;
180
+ try {
181
+ return wrapCelAst(parse(trimmed).ast, segmentStart);
182
+ } catch {
183
+ // try the next-shorter prefix
184
+ }
185
+ }
186
+ return { kind: "ident", range, name: source.trim() };
187
+ }
188
+
189
+ const OPEN_MARKER = "${{";
190
+
191
+ /** Build the CEL segments of a scalar from its raw source slice. `scalarText`
192
+ * is `text.slice(start, valueEnd)` and `scalarStart` its document offset.
193
+ *
194
+ * - `tag === "!cel"` → one closed segment spanning the tagged body.
195
+ * - otherwise → one closed segment per `${{ … }}` match, plus a trailing
196
+ * `open` segment for a dangling `${{` with no `}}` (bounded to its line, so
197
+ * an unterminated quote that swallowed following lines still recovers the
198
+ * region the user is typing in). */
199
+ export function buildCelSegments(
200
+ scalarText: string,
201
+ scalarStart: number,
202
+ tag: string | undefined,
203
+ taggedSource: string | undefined,
204
+ ): CelSegment[] {
205
+ if (tag === "!cel" && taggedSource != null) {
206
+ const idx = scalarText.indexOf(taggedSource);
207
+ const bodyStart = scalarStart + (idx >= 0 ? idx : 0);
208
+ const range: [number, number] = [bodyStart, bodyStart + taggedSource.length];
209
+ return [
210
+ {
211
+ range,
212
+ source: taggedSource,
213
+ open: false,
214
+ ast: () => wrapCelAst(parse(taggedSource).ast, bodyStart),
215
+ },
216
+ ];
217
+ }
218
+
219
+ const segments: CelSegment[] = [];
220
+ const re = /\$\{\{([\s\S]*?)\}\}/g;
221
+ let match: RegExpExecArray | null;
222
+ let lastClosedEnd = 0;
223
+ while ((match = re.exec(scalarText)) !== null) {
224
+ const whole = match[0];
225
+ const inner = match[1];
226
+ const leadingWs = inner.match(/^\s*/)?.[0].length ?? 0;
227
+ const bodyStart = scalarStart + match.index + OPEN_MARKER.length + leadingWs;
228
+ const source = inner.trim();
229
+ segments.push({
230
+ range: [scalarStart + match.index, scalarStart + match.index + whole.length],
231
+ source,
232
+ open: false,
233
+ ast: () => wrapCelAst(parse(source).ast, bodyStart),
234
+ });
235
+ lastClosedEnd = match.index + whole.length;
236
+ }
237
+
238
+ const openIdx = scalarText.indexOf(OPEN_MARKER, lastClosedEnd);
239
+ if (openIdx >= 0 && scalarText.indexOf("}}", openIdx) < 0) {
240
+ let lineEnd = scalarText.indexOf("\n", openIdx);
241
+ if (lineEnd < 0) lineEnd = scalarText.length;
242
+ const after = openIdx + OPEN_MARKER.length;
243
+ // Drop a trailing scalar-closing quote so `foo: "${{ req"` recovers `req`,
244
+ // not `req"` — the quote closes the YAML string, it isn't part of the CEL.
245
+ const rawBody = scalarText.slice(after, lineEnd).replace(/["']\s*$/, "");
246
+ const leadingWs = rawBody.match(/^\s*/)?.[0].length ?? 0;
247
+ const bodyStart = scalarStart + after + leadingWs;
248
+ const source = rawBody.trim();
249
+ const range: [number, number] = [scalarStart + openIdx, scalarStart + lineEnd];
250
+ segments.push({
251
+ range,
252
+ source,
253
+ open: true,
254
+ ast: () => parseLenient(source, bodyStart, range),
255
+ });
256
+ }
257
+
258
+ return segments;
259
+ }
@@ -0,0 +1,66 @@
1
+ import type { GraphLoadError, LoadedGraph } from "./loaded-types.js";
2
+ import { isLocalPathSource } from "./sources/local-path-ref.js";
3
+ import { isRegistryRef } from "./sources/module-ref.js";
4
+ import { isOciRef } from "./sources/oci-ref.js";
5
+ import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
6
+
7
+ const SOURCE = "telo-analyzer";
8
+
9
+ /** True when `source` is a shape some transport claims — a registry ref, an OCI
10
+ * ref, an HTTP(S) URL, or a relative/absolute path. A source matching none of
11
+ * these is malformed (no transport can ever resolve it), which we report
12
+ * differently from a well-formed ref that simply failed to fetch. */
13
+ function isRecognizedSourceShape(source: string): boolean {
14
+ return (
15
+ isRegistryRef(source) ||
16
+ isOciRef(source) ||
17
+ source.startsWith("http://") ||
18
+ source.startsWith("https://") ||
19
+ isLocalPathSource(source)
20
+ );
21
+ }
22
+
23
+ function messageFor(e: GraphLoadError, malformed: boolean): string {
24
+ const authored = e.source ?? e.url;
25
+ const via = e.alias ? `import '${e.alias}' → '${authored}'` : `'${authored}'`;
26
+ if (malformed) {
27
+ return (
28
+ `Cannot resolve ${via}: not a recognized module reference. Expected ` +
29
+ `'namespace/name@version', 'oci://host/repo@tag', 'https://…', or a relative path.`
30
+ );
31
+ }
32
+ return `Cannot resolve ${via}: ${e.error.message}`;
33
+ }
34
+
35
+ /**
36
+ * Convert a graph's import-resolution failures (`graph.errors`) into structured,
37
+ * coded diagnostics. This is the single source of truth for surfacing a broken
38
+ * import — every host (CLI, VS Code, telo-editor) routes these instead of each
39
+ * re-deriving the channel and drifting (the VS Code extension used to drop it
40
+ * entirely, showing nothing for a broken import).
41
+ *
42
+ * The analyzer owns only this raw channel conversion; the *presentation* policy
43
+ * — which analysis cascade to hold back for a compromised file — lives in
44
+ * `@telorun/ide-support`'s `assembleGraphDiagnostics`.
45
+ *
46
+ * Each diagnostic adopts the same `data` shape as version-reconciliation
47
+ * diagnostics — `{ filePath, path: "imports.<alias>" }` — so the shared
48
+ * `findPositions` / `resolveRange` routing anchors it on the offending import
49
+ * line with no host-specific code.
50
+ */
51
+ export function importResolutionDiagnostics(graph: LoadedGraph): AnalysisDiagnostic[] {
52
+ return graph.errors.map((e) => {
53
+ const filePath = e.fromSource ?? graph.entry.owner.source;
54
+ const malformed = !isRecognizedSourceShape(e.source ?? e.url);
55
+ const data: { filePath: string; path?: string; sourceLine?: number } = { filePath };
56
+ if (e.alias) data.path = `imports.${e.alias}`;
57
+ if (e.sourceLine !== undefined) data.sourceLine = e.sourceLine;
58
+ return {
59
+ severity: DiagnosticSeverity.Error,
60
+ code: malformed ? "INVALID_IMPORT_SOURCE" : "IMPORT_UNRESOLVED",
61
+ source: SOURCE,
62
+ message: messageFor(e, malformed),
63
+ data,
64
+ };
65
+ });
66
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { AnalysisRegistry } from "./analysis-registry.js";
2
2
  export type { RefFieldInfo } from "./analysis-registry.js";
3
3
  export { StaticAnalyzer } from "./analyzer.js";
4
+ export { importResolutionDiagnostics } from "./import-resolution-diagnostics.js";
4
5
  export type {
5
6
  GraphLoadError,
6
7
  ImportEdge,
@@ -67,6 +68,7 @@ export {
67
68
  buildLineOffsets,
68
69
  buildPositionIndex,
69
70
  documentLineOffsets,
71
+ offsetToPosition,
70
72
  } from "./position-metadata.js";
71
73
  export type { DocumentPosition } from "./position-metadata.js";
72
74
  export { HttpSource } from "./sources/http-source.js";
@@ -84,6 +86,7 @@ export { parseModuleRef, isRegistryRef } from "./sources/module-ref.js";
84
86
  export type { ParsedModuleRef } from "./sources/module-ref.js";
85
87
  export { OCI_SCHEME, isOciRef, parseOciRef } from "./sources/oci-ref.js";
86
88
  export type { ParsedOciRef } from "./sources/oci-ref.js";
89
+ export { isLocalPathSource } from "./sources/local-path-ref.js";
87
90
  export {
88
91
  MANIFEST_CACHE_BASE_URL,
89
92
  ManifestCacheSource,
@@ -95,6 +98,10 @@ export {
95
98
  } from "./sources/manifest-cache.js";
96
99
  export type { ManifestCacheCoords } from "./sources/manifest-cache.js";
97
100
  export { withSyntheticPositions } from "./with-synthetic-positions.js";
101
+ export { documentToAst, parseToAst } from "./yaml-ast.js";
102
+ export type { AstDocument, AstMap, AstNode, AstPair, AstScalar, AstSeq } from "./yaml-ast.js";
103
+ export { buildCelSegments, wrapCelAst } from "./cel-ast.js";
104
+ export type { CelNode, CelSegment } from "./cel-ast.js";
98
105
  export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
99
106
  export type {
100
107
  AnalysisDiagnostic,
@@ -2,6 +2,7 @@ import type { ResourceManifest } from "@telorun/sdk";
2
2
  import type { Document } from "yaml";
3
3
  import type { DocumentPosition } from "./position-metadata.js";
4
4
  import type { AnalysisDiagnostic, Range } from "./types.js";
5
+ import type { AstDocument } from "./yaml-ast.js";
5
6
 
6
7
  /** One physical file's parsed result. Returned for the owner manifest, for
7
8
  * each `include:` partial, and for each external import target.
@@ -17,8 +18,13 @@ export interface LoadedFile {
17
18
  requestedUrl: string;
18
19
  /** Raw text exactly as `read()` returned it. */
19
20
  text: string;
20
- /** Per-document parsed AST, in source order. */
21
+ /** Per-document parsed `yaml` AST, in source order. The editor's mutable
22
+ * round-trip model reads this handle; structure-only consumers use the
23
+ * read-only `astDocuments` instead so `yaml` stays an internal detail. */
21
24
  documents: Document[];
25
+ /** Per-document read-only `AstNode` view (`yaml`-free), aligned to
26
+ * `documents`. The shared structural source of truth for IDE features. */
27
+ astDocuments: AstDocument[];
22
28
  /** Per-document JSON projection (`doc.toJSON()`). Aligned to `documents`. */
23
29
  manifests: Array<ResourceManifest | null>;
24
30
  /** Per-document `{sourceLine, positionIndex}`. Aligned to `documents`. */
@@ -96,9 +102,20 @@ export interface LoadedGraph {
96
102
  }
97
103
 
98
104
  export interface GraphLoadError {
99
- /** URL of the file that failed to load. */
105
+ /** URL of the file that failed to load (resolved — may be a `file://` URL for
106
+ * a relative import). */
100
107
  url: string;
108
+ /** The import source string exactly as authored (`./lib`, `std/x@1.0.0`),
109
+ * before relative-path resolution. Preferred over `url` for classification
110
+ * and display, so a diagnostic quotes what the author wrote. */
111
+ source?: string;
101
112
  /** Source of the import that triggered the load, or null for the entry. */
102
113
  fromSource: string | null;
114
+ /** Import alias the failed source was bound to in `fromSource`'s `imports:`
115
+ * map, when the failure is a transitive import (absent for an entry-load
116
+ * failure). Lets a consumer anchor the diagnostic at `imports.<alias>`. */
117
+ alias?: string;
118
+ /** Line of the `Telo.Import` doc in `fromSource`, for position fallback. */
119
+ sourceLine?: number;
103
120
  error: Error;
104
121
  }
@@ -258,7 +258,10 @@ export class Loader {
258
258
  } catch (err) {
259
259
  errors.push({
260
260
  url: importSource,
261
+ source: importSource,
261
262
  fromSource: file.source,
263
+ alias,
264
+ sourceLine,
262
265
  error: err instanceof Error ? err : new Error(String(err)),
263
266
  });
264
267
  continue;
@@ -284,7 +287,14 @@ export class Loader {
284
287
  } catch (err) {
285
288
  const e = err instanceof Error ? err : new Error(String(err));
286
289
  (e as { sourceLine?: number }).sourceLine = sourceLine;
287
- errors.push({ url: resolvedTarget, fromSource: file.source, error: e });
290
+ errors.push({
291
+ url: resolvedTarget,
292
+ source: importSource,
293
+ fromSource: file.source,
294
+ alias,
295
+ sourceLine,
296
+ error: e,
297
+ });
288
298
  continue;
289
299
  }
290
300
  }
@@ -6,6 +6,7 @@ import { buildCelEnvironment } from "./cel-environment.js";
6
6
  import type { LoadedFile, ParseError } from "./loaded-types.js";
7
7
  import { buildDocumentPositions } from "./position-metadata.js";
8
8
  import { precompileDoc } from "./precompile.js";
9
+ import { documentToAst } from "./yaml-ast.js";
9
10
 
10
11
  export interface ParseOptions {
11
12
  /** When true, runs `precompileDoc` per document and stamps compiled CEL
@@ -49,7 +50,8 @@ export function parseLoadedFile(
49
50
  options?: ParseOptions,
50
51
  ): LoadedFile {
51
52
  const documents = parseAllDocuments(text, { customTags: defaultCustomTags() });
52
- const positions = buildDocumentPositions(text, documents);
53
+ const astDocuments = documents.map((doc) => documentToAst(doc, text));
54
+ const positions = buildDocumentPositions(text, astDocuments);
53
55
 
54
56
  const parseErrors: ParseError[] = [];
55
57
  documents.forEach((doc, documentIndex) => {
@@ -90,6 +92,7 @@ export function parseLoadedFile(
90
92
  requestedUrl,
91
93
  text,
92
94
  documents,
95
+ astDocuments,
93
96
  manifests,
94
97
  positions,
95
98
  parseErrors,