@telorun/ide-support 0.15.0 → 0.16.1

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 (75) hide show
  1. package/dist/cel/cursor-chain.d.ts +30 -0
  2. package/dist/cel/cursor-chain.d.ts.map +1 -0
  3. package/dist/cel/cursor-chain.js +30 -0
  4. package/dist/cel/symbols.d.ts +82 -0
  5. package/dist/cel/symbols.d.ts.map +1 -0
  6. package/dist/cel/symbols.js +147 -0
  7. package/dist/cel/tokens.d.ts +32 -0
  8. package/dist/cel/tokens.d.ts.map +1 -0
  9. package/dist/cel/tokens.js +162 -0
  10. package/dist/completions/build.d.ts +6 -2
  11. package/dist/completions/build.d.ts.map +1 -1
  12. package/dist/completions/build.js +52 -24
  13. package/dist/completions/call-inputs.d.ts +25 -0
  14. package/dist/completions/call-inputs.d.ts.map +1 -0
  15. package/dist/completions/call-inputs.js +78 -0
  16. package/dist/completions/cel-completions.d.ts +26 -0
  17. package/dist/completions/cel-completions.d.ts.map +1 -0
  18. package/dist/completions/cel-completions.js +78 -0
  19. package/dist/completions/detect-context.d.ts +47 -8
  20. package/dist/completions/detect-context.d.ts.map +1 -1
  21. package/dist/completions/detect-context.js +51 -15
  22. package/dist/completions/prop-keys.d.ts +5 -1
  23. package/dist/completions/prop-keys.d.ts.map +1 -1
  24. package/dist/completions/prop-keys.js +51 -3
  25. package/dist/completions/resolve-node.d.ts +9 -2
  26. package/dist/completions/resolve-node.d.ts.map +1 -1
  27. package/dist/completions/resolve-node.js +63 -21
  28. package/dist/completions/valid-capabilities.js +1 -1
  29. package/dist/definition/build-definition.d.ts +6 -2
  30. package/dist/definition/build-definition.d.ts.map +1 -1
  31. package/dist/definition/build-definition.js +16 -3
  32. package/dist/definition/locate-context-binding.d.ts +15 -0
  33. package/dist/definition/locate-context-binding.d.ts.map +1 -0
  34. package/dist/definition/locate-context-binding.js +35 -0
  35. package/dist/definition/locate-step.d.ts +13 -0
  36. package/dist/definition/locate-step.d.ts.map +1 -0
  37. package/dist/definition/locate-step.js +33 -0
  38. package/dist/definition/resolve-cel-target.d.ts +11 -1
  39. package/dist/definition/resolve-cel-target.d.ts.map +1 -1
  40. package/dist/definition/resolve-cel-target.js +14 -14
  41. package/dist/doc-identity.d.ts +17 -0
  42. package/dist/doc-identity.d.ts.map +1 -0
  43. package/dist/doc-identity.js +19 -0
  44. package/dist/hover/build-hover.d.ts +6 -2
  45. package/dist/hover/build-hover.d.ts.map +1 -1
  46. package/dist/hover/build-hover.js +64 -3
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +4 -0
  50. package/dist/semantic-tokens/build-semantic-tokens.d.ts +13 -8
  51. package/dist/semantic-tokens/build-semantic-tokens.d.ts.map +1 -1
  52. package/dist/semantic-tokens/build-semantic-tokens.js +81 -37
  53. package/dist/types.d.ts +25 -5
  54. package/dist/types.d.ts.map +1 -1
  55. package/dist/types.js +16 -2
  56. package/package.json +2 -2
  57. package/src/cel/cursor-chain.ts +58 -0
  58. package/src/cel/symbols.ts +189 -0
  59. package/src/cel/tokens.ts +169 -0
  60. package/src/completions/build.ts +85 -22
  61. package/src/completions/call-inputs.ts +92 -0
  62. package/src/completions/cel-completions.ts +108 -0
  63. package/src/completions/detect-context.ts +107 -13
  64. package/src/completions/prop-keys.ts +59 -2
  65. package/src/completions/resolve-node.ts +82 -17
  66. package/src/completions/valid-capabilities.ts +1 -1
  67. package/src/definition/build-definition.ts +30 -2
  68. package/src/definition/locate-context-binding.ts +53 -0
  69. package/src/definition/locate-step.ts +50 -0
  70. package/src/definition/resolve-cel-target.ts +25 -0
  71. package/src/doc-identity.ts +31 -0
  72. package/src/hover/build-hover.ts +67 -1
  73. package/src/index.ts +4 -0
  74. package/src/semantic-tokens/build-semantic-tokens.ts +84 -30
  75. package/src/types.ts +47 -6
@@ -1,4 +1,10 @@
1
- import { parseToAst, readRefSlot, refSlotAnnotation, type AstDocument } from "@telorun/analyzer";
1
+ import {
2
+ parseToAst,
3
+ readRefSlot,
4
+ refSlotAnnotation,
5
+ type AstDocument,
6
+ type CelSegment,
7
+ } from "@telorun/analyzer";
2
8
  import type { ReplaceRange } from "../types.js";
3
9
  import { resolveNodeAtPosition } from "./resolve-node.js";
4
10
 
@@ -19,7 +25,17 @@ export type CompletionCtx =
19
25
  replaceRange: ReplaceRange;
20
26
  }
21
27
  | { type: "capability" }
22
- | { type: "prop-key"; docKind: string; yamlPath: string[]; existingKeys: Set<string> }
28
+ | {
29
+ type: "prop-key";
30
+ docKind: string;
31
+ yamlPath: string[];
32
+ /** The same location with sequence indices kept, so a caller can find the
33
+ * manifest node this slot sits in — what resolving an enclosing call's
34
+ * reference needs. */
35
+ concretePath: string;
36
+ docIndex: number;
37
+ existingKeys: Set<string>;
38
+ }
23
39
  | {
24
40
  /** Cursor sits on the value of an object-form ref's `name:` field
25
41
  * (e.g. `connection: { kind: Sql.Connection, name: |}`). Editor hosts
@@ -43,6 +59,35 @@ export type CompletionCtx =
43
59
  prefix: string;
44
60
  /** Full source range of the value being completed. */
45
61
  replaceRange: ReplaceRange;
62
+ }
63
+ | {
64
+ /** Cursor sits on an ordinary field VALUE whose schema declares the values
65
+ * it may take — `enum` (closed) or `examples` (open). Untargeted on
66
+ * purpose: every value slot resolves here and the ones declaring neither
67
+ * simply offer nothing. */
68
+ type: "value-suggestions";
69
+ docKind: string;
70
+ /** Path from the document root to the field, so its schema can be found. */
71
+ yamlPath: string[];
72
+ replaceRange: ReplaceRange;
73
+ }
74
+ | {
75
+ /** Cursor sits inside a CEL body — closed or still open (`!cel "req.|`).
76
+ * What completes is decided by the scope the analyzer resolves for this
77
+ * site, so the host must supply a `CelScopeQuery`; without one the
78
+ * candidate list would be a guess rather than a claim about what
79
+ * `telo check` accepts, and nothing is offered. */
80
+ type: "cel";
81
+ docKind?: string;
82
+ /** Which `---` document the cursor is in, so the host can name the
83
+ * resource this expression belongs to. */
84
+ docIndex: number;
85
+ /** The site's address with sequence indices kept — what the scope is
86
+ * resolved at. */
87
+ concretePath: string;
88
+ segment: CelSegment;
89
+ /** Cursor as a document offset. */
90
+ offset: number;
46
91
  };
47
92
 
48
93
  /** Returns every schema branch reachable from `node` after peeling `anyOf` /
@@ -116,19 +161,52 @@ function resolveLocalRef(
116
161
  return current;
117
162
  }
118
163
 
164
+ /**
165
+ * Resolves an `x-telo-schema-from` annotation to the schema it derives. Supplied
166
+ * by the caller because the anchor is alias-qualified and only the registry can
167
+ * resolve it in the declaring kind's module scope.
168
+ */
169
+ export type SchemaFromResolver = (schemaFrom: string) => Record<string, any> | undefined;
170
+
171
+ /**
172
+ * Expand a node whose shape comes from a sibling kind's schema.
173
+ *
174
+ * A slot annotated `x-telo-schema-from` declares NO `properties` of its own — an
175
+ * `Http.Api` route's `request:` is exactly this — so every walk that reads
176
+ * `properties` finds an empty node and silently offers nothing. The derived
177
+ * schema is merged UNDER whatever the slot itself declared, so a slot that adds
178
+ * a title or narrows a field keeps winning.
179
+ */
180
+ function resolveSchemaFrom(
181
+ node: Record<string, any> | undefined,
182
+ resolve: SchemaFromResolver | undefined,
183
+ ): Record<string, any> | undefined {
184
+ const from = node?.["x-telo-schema-from"];
185
+ if (!node || !resolve || typeof from !== "string") return node;
186
+ const derived = resolve(from);
187
+ if (!derived) return node;
188
+ return {
189
+ ...derived,
190
+ ...node,
191
+ properties: { ...(derived.properties ?? {}), ...(node.properties ?? {}) },
192
+ };
193
+ }
194
+
119
195
  /** Navigate a JSON Schema hierarchy following `path`, auto-descending into
120
- * array items, peeling `anyOf` / `oneOf` branches and following document-local
121
- * `$ref`s. When multiple peeled branches define `properties`, returns a
122
- * synthetic node whose `properties` is the union (first-wins on key collision)
123
- * and whose `required` is the intersection — enough for propKeyCompletions to
124
- * surface every key a value at this slot can legally carry. */
196
+ * array items, peeling `anyOf` / `oneOf` branches, following document-local
197
+ * `$ref`s and expanding `x-telo-schema-from` slots. When multiple peeled
198
+ * branches define `properties`, returns a synthetic node whose `properties` is
199
+ * the union (first-wins on key collision) and whose `required` is the
200
+ * intersection — enough for propKeyCompletions to surface every key a value at
201
+ * this slot can legally carry. */
125
202
  export function navigateSchema(
126
203
  schema: Record<string, any>,
127
204
  path: string[],
205
+ schemaFrom?: SchemaFromResolver,
128
206
  ): Record<string, any> | undefined {
129
207
  let current: Record<string, any> | undefined = schema;
130
208
  for (const segment of path) {
131
- current = resolveLocalRef(current, schema);
209
+ current = resolveSchemaFrom(resolveLocalRef(current, schema), schemaFrom);
132
210
  if (!current) return undefined;
133
211
  const candidates = peelCombinators(current).flatMap((node) => {
134
212
  const expanded: Record<string, any>[] = [];
@@ -161,7 +239,7 @@ export function navigateSchema(
161
239
  if (!next) return undefined;
162
240
  current = next;
163
241
  }
164
- current = resolveLocalRef(current, schema);
242
+ current = resolveSchemaFrom(resolveLocalRef(current, schema), schemaFrom);
165
243
  if (!current) return undefined;
166
244
  // Auto-descend through a trailing array at the leaf (e.g. cursor inside `mounts:` items)
167
245
  while (current.type === "array" && current.items) {
@@ -227,8 +305,9 @@ function unionLeaves(
227
305
  export function lookupRefConstraints(
228
306
  definitionSchema: Record<string, any>,
229
307
  yamlPath: string[],
308
+ schemaFrom?: SchemaFromResolver,
230
309
  ): string[] {
231
- const node = navigateSchema(definitionSchema, yamlPath);
310
+ const node = navigateSchema(definitionSchema, yamlPath, schemaFrom);
232
311
  if (!node) return [];
233
312
  return readRefSlot(node)?.kinds ?? [];
234
313
  }
@@ -249,9 +328,18 @@ export function detectContext(
249
328
  const { docKind } = resolved;
250
329
 
251
330
  if (resolved.slot === "value") {
252
- // Inside a CEL body — structural completion does not apply (a future
253
- // CEL-completion feature consumes `resolved.cel`).
254
- if (resolved.cel) return undefined;
331
+ // Inside a CEL body — structural completion does not apply; what completes
332
+ // are the names the expression may use.
333
+ if (resolved.cel) {
334
+ return {
335
+ type: "cel",
336
+ docKind,
337
+ docIndex: resolved.docIndex,
338
+ concretePath: resolved.concretePath ?? "",
339
+ segment: resolved.cel.segment,
340
+ offset: resolved.cel.offset,
341
+ };
342
+ }
255
343
  const replaceRange = resolved.replaceRange;
256
344
  if (!replaceRange) return undefined;
257
345
  const key = resolved.path[resolved.path.length - 1];
@@ -297,6 +385,10 @@ export function detectContext(
297
385
  }
298
386
  }
299
387
 
388
+ if (docKind) {
389
+ return { type: "value-suggestions", docKind, yamlPath: resolved.path, replaceRange };
390
+ }
391
+
300
392
  return undefined;
301
393
  }
302
394
 
@@ -311,6 +403,8 @@ export function detectContext(
311
403
  type: "prop-key",
312
404
  docKind: scopeKind,
313
405
  yamlPath: resolved.path.slice(resolved.resourceDepth ?? 0),
406
+ concretePath: resolved.concretePath ?? "",
407
+ docIndex: resolved.docIndex,
314
408
  existingKeys: resolved.existingKeys ?? new Set<string>(),
315
409
  };
316
410
  }
@@ -25,7 +25,18 @@ export function propKeyCompletions(
25
25
  yamlPath: string[],
26
26
  existingKeys: Set<string>,
27
27
  registry: AnalysisRegistry | undefined,
28
+ /** The arguments the enclosing call declares, when this path IS that call's
29
+ * argument slot. Resolved by the caller, which is the side holding the
30
+ * manifest the reference names. */
31
+ callInputs?: Record<string, any>,
28
32
  ): CompletionResult[] {
33
+ if (callInputs?.properties) {
34
+ return buildItems(
35
+ callInputs.properties as Record<string, any>,
36
+ existingKeys,
37
+ new Set<string>(Array.isArray(callInputs.required) ? callInputs.required : []),
38
+ );
39
+ }
29
40
  if (!registry) return [];
30
41
 
31
42
  const definition = registry.resolveDefinition(kind);
@@ -41,9 +52,20 @@ export function propKeyCompletions(
41
52
 
42
53
  const targetSchema = yamlPath.length === 0
43
54
  ? (definition.schema as Record<string, any>)
44
- : navigateSchema(definition.schema as Record<string, any>, yamlPath);
55
+ : navigateSchema(definition.schema as Record<string, any>, yamlPath, (from) =>
56
+ registry.resolveSchemaFrom(from, kind),
57
+ );
58
+
59
+ // A NAME-KEYED map declares its keys nowhere in `properties` — they are the
60
+ // author's own (a media type, a header name). `propertyNames` is JSON
61
+ // Schema's own vocabulary for what they may be, and it carries the open/closed
62
+ // distinction already: `enum` constrains, `examples` only suggests. So an open
63
+ // list of known values needs no annotation and no analyzer-side knowledge of
64
+ // any domain.
65
+ const keySuggestions = propertyNameSuggestions(targetSchema, existingKeys);
45
66
 
46
67
  if (!targetSchema?.properties) {
68
+ if (keySuggestions.length > 0) return keySuggestions;
47
69
  if (yamlPath.length === 0) {
48
70
  return buildItems(ROOT_IMPLICIT_PROPS, existingKeys, new Set<string>());
49
71
  }
@@ -58,7 +80,42 @@ export function propKeyCompletions(
58
80
  ? { ...ROOT_IMPLICIT_PROPS, ...(targetSchema.properties as Record<string, any>) }
59
81
  : { ...(targetSchema.properties as Record<string, any>), ...annotationKeys(targetSchema) };
60
82
 
61
- return buildItems(properties, existingKeys, required);
83
+ return [...keySuggestions, ...buildItems(properties, existingKeys, required)];
84
+ }
85
+
86
+ /**
87
+ * Key candidates a map-valued node declares through `propertyNames`.
88
+ *
89
+ * `enum` is a closed set and `examples` an open one — suggestions with no
90
+ * validation effect, which is exactly "these are the known values, others are
91
+ * allowed". Both are stock JSON Schema, so nothing here knows what a media type
92
+ * is and any name-keyed field gains the same behaviour by declaring it.
93
+ */
94
+ function propertyNameSuggestions(
95
+ schema: Record<string, any> | undefined,
96
+ existingKeys: Set<string>,
97
+ ): CompletionResult[] {
98
+ const names = schema?.propertyNames as Record<string, any> | undefined;
99
+ if (!names || typeof names !== "object") return [];
100
+ const closed = Array.isArray(names.enum) ? (names.enum as unknown[]) : undefined;
101
+ const open = Array.isArray(names.examples) ? (names.examples as unknown[]) : [];
102
+ const values = closed ?? open;
103
+
104
+ const out: CompletionResult[] = [];
105
+ for (const value of values) {
106
+ if (typeof value !== "string" || existingKeys.has(value)) continue;
107
+ out.push({
108
+ label: value,
109
+ kind: "enumMember",
110
+ insertText: `${value}: $0`,
111
+ snippet: true,
112
+ detail: closed ? names.title ?? "allowed key" : names.title ?? "known key",
113
+ // Ahead of any structural key at the same level: at a name-keyed slot the
114
+ // author is choosing one of these, not adding a sibling field.
115
+ sortText: `0_${value}`,
116
+ });
117
+ }
118
+ return out;
62
119
  }
63
120
 
64
121
  /**
@@ -48,8 +48,15 @@ export interface ResolvedCursor {
48
48
  /** Key slot: number of `path` segments that reach `resourceKind`'s map, so
49
49
  * the schema-relative path is `path.slice(resourceDepth)`. */
50
50
  resourceDepth?: number;
51
- /** Set when the cursor sits inside a CEL body (closed or open). Populated for
52
- * a future CEL-completion feature; this refactor does not consume it. */
51
+ /** The path with sequence INDICES kept (`routes[0].handler.url`),
52
+ * as distinct from `path`, through which arrays are transparent. This is the
53
+ * address the analyzer speaks — an `x-telo-context` scope, an error-bearing
54
+ * region and a step's identity are all resolved per item — so it is what a
55
+ * CEL site is looked up by. */
56
+ concretePath?: string;
57
+ /** Set when the cursor sits inside a CEL body (closed or open). The CEL
58
+ * segment plus the cursor's document offset — what completion and hover
59
+ * hit-test the expression's chain against. */
53
60
  cel?: { segment: CelSegment; offset: number };
54
61
  }
55
62
 
@@ -109,6 +116,7 @@ type Descent =
109
116
  type: "key";
110
117
  container: AstMap;
111
118
  path: string[];
119
+ concretePath: string;
112
120
  keyNode: AstScalar;
113
121
  keyName?: string;
114
122
  scope: ResourceScope;
@@ -119,15 +127,22 @@ type Descent =
119
127
  * (which has no keyed siblings). */
120
128
  container: AstMap | undefined;
121
129
  path: string[];
130
+ concretePath: string;
122
131
  keyName?: string;
123
132
  keyEnd: number;
124
133
  valueNode: AstNode;
125
134
  }
126
135
  | { type: "empty" };
127
136
 
137
+ /** Append one key segment to a concrete path (`routes[0]` + `handler`). */
138
+ function joinKey(concrete: string, key: string): string {
139
+ return concrete ? `${concrete}.${key}` : key;
140
+ }
141
+
128
142
  function descend(
129
143
  node: AstNode,
130
144
  ancestors: string[],
145
+ concrete: string,
131
146
  offset: number,
132
147
  scope: ResourceScope,
133
148
  ): Descent | undefined {
@@ -140,6 +155,7 @@ function descend(
140
155
  type: "key",
141
156
  container: node,
142
157
  path: ancestors,
158
+ concretePath: concrete,
143
159
  keyNode: pair.key as AstScalar,
144
160
  keyName,
145
161
  scope: mapScope,
@@ -147,13 +163,15 @@ function descend(
147
163
  }
148
164
  if (pair.value && within(pair.value.range, offset)) {
149
165
  const childAncestors = keyName != null ? [...ancestors, keyName] : ancestors;
166
+ const childConcrete = keyName != null ? joinKey(concrete, keyName) : concrete;
150
167
  if (pair.value.kind === "map" || pair.value.kind === "seq") {
151
- return descend(pair.value, childAncestors, offset, mapScope) ?? { type: "empty" };
168
+ return descend(pair.value, childAncestors, childConcrete, offset, mapScope) ?? { type: "empty" };
152
169
  }
153
170
  return {
154
171
  type: "value",
155
172
  container: node,
156
173
  path: ancestors,
174
+ concretePath: childConcrete,
157
175
  keyName,
158
176
  keyEnd: pair.key.range[1],
159
177
  valueNode: pair.value,
@@ -164,16 +182,26 @@ function descend(
164
182
  }
165
183
  if (node.kind === "seq") {
166
184
  // Sequence items are transparent to the key path (mirrors the schema
167
- // walker, which auto-descends arrays).
168
- for (const item of node.items) {
185
+ // walker, which auto-descends arrays) but NOT to the concrete path: an
186
+ // `x-telo-context` scope, an error-bearing region and a step's identity are
187
+ // all addressed per item, so a CEL site is unreachable without the index.
188
+ for (const [index, item] of node.items.entries()) {
169
189
  if (within(item.range, offset)) {
190
+ const itemConcrete = `${concrete}[${index}]`;
170
191
  if (item.kind === "map" || item.kind === "seq") {
171
- return descend(item, ancestors, offset, scope) ?? { type: "empty" };
192
+ return descend(item, ancestors, itemConcrete, offset, scope) ?? { type: "empty" };
172
193
  }
173
194
  // A bare scalar list item (`targets:\n - One`) has no enclosing map of
174
195
  // keyed siblings — leave `container` undefined rather than treating the
175
196
  // seq as a map.
176
- return { type: "value", container: undefined, path: ancestors, keyEnd: item.range[0], valueNode: item };
197
+ return {
198
+ type: "value",
199
+ container: undefined,
200
+ path: ancestors,
201
+ concretePath: itemConcrete,
202
+ keyEnd: item.range[0],
203
+ valueNode: item,
204
+ };
177
205
  }
178
206
  }
179
207
  return undefined;
@@ -187,6 +215,9 @@ function descend(
187
215
 
188
216
  interface MapScope {
189
217
  path: string[];
218
+ /** The same location with sequence indices kept — what addresses a manifest
219
+ * node, as distinct from `path`, which addresses a schema node. */
220
+ concrete: string;
190
221
  childColumn: number;
191
222
  keys: Set<string>;
192
223
  rangeStart: number;
@@ -195,6 +226,7 @@ interface MapScope {
195
226
 
196
227
  interface PairScope {
197
228
  path: string[]; // full key path to this pair
229
+ concrete: string;
198
230
  keyColumn: number;
199
231
  keyOffset: number;
200
232
  childKeys: Set<string>;
@@ -204,6 +236,7 @@ interface PairScope {
204
236
  function collectScopes(
205
237
  node: AstNode,
206
238
  ancestors: string[],
239
+ concrete: string,
207
240
  scope: ResourceScope,
208
241
  lineOffsets: number[],
209
242
  maps: MapScope[],
@@ -219,11 +252,19 @@ function collectScopes(
219
252
  if (childColumn < 0) childColumn = offsetToPosition(pair.key.range[0], lineOffsets).character;
220
253
  }
221
254
  if (childColumn >= 0) {
222
- maps.push({ path: ancestors, childColumn, keys, rangeStart: node.range[0], scope: mapScope });
255
+ maps.push({
256
+ path: ancestors,
257
+ concrete,
258
+ childColumn,
259
+ keys,
260
+ rangeStart: node.range[0],
261
+ scope: mapScope,
262
+ });
223
263
  }
224
264
  for (const pair of node.entries) {
225
265
  const keyName = scalarString(pair.key);
226
266
  const fullPath = keyName != null ? [...ancestors, keyName] : ancestors;
267
+ const fullConcrete = keyName != null ? joinKey(concrete, keyName) : concrete;
227
268
  const childKeys = new Set<string>();
228
269
  if (pair.value?.kind === "map") {
229
270
  for (const p of pair.value.entries) {
@@ -233,20 +274,26 @@ function collectScopes(
233
274
  }
234
275
  pairs.push({
235
276
  path: fullPath,
277
+ concrete: fullConcrete,
236
278
  keyColumn: offsetToPosition(pair.key.range[0], lineOffsets).character,
237
279
  keyOffset: pair.key.range[0],
238
280
  childKeys,
239
281
  scope: mapScope,
240
282
  });
241
- if (pair.value) collectScopes(pair.value, fullPath, mapScope, lineOffsets, maps, pairs);
283
+ if (pair.value) {
284
+ collectScopes(pair.value, fullPath, fullConcrete, mapScope, lineOffsets, maps, pairs);
285
+ }
242
286
  }
243
287
  } else if (node.kind === "seq") {
244
- for (const item of node.items) collectScopes(item, ancestors, scope, lineOffsets, maps, pairs);
288
+ node.items.forEach((item, index) =>
289
+ collectScopes(item, ancestors, `${concrete}[${index}]`, scope, lineOffsets, maps, pairs),
290
+ );
245
291
  }
246
292
  }
247
293
 
248
294
  interface KeyResolution {
249
295
  path: string[];
296
+ concrete: string;
250
297
  existingKeys: Set<string>;
251
298
  scope: ResourceScope;
252
299
  }
@@ -262,7 +309,7 @@ function columnSearch(
262
309
  ): KeyResolution {
263
310
  const maps: MapScope[] = [];
264
311
  const pairs: PairScope[] = [];
265
- collectScopes(root, [], { depth: 0 }, lineOffsets, maps, pairs);
312
+ collectScopes(root, [], "", { depth: 0 }, lineOffsets, maps, pairs);
266
313
 
267
314
  // Sibling level: a map whose children already sit at the cursor's column.
268
315
  let sibling: MapScope | undefined;
@@ -271,7 +318,14 @@ function columnSearch(
271
318
  if (!sibling || m.rangeStart > sibling.rangeStart) sibling = m;
272
319
  }
273
320
  }
274
- if (sibling) return { path: sibling.path, existingKeys: sibling.keys, scope: sibling.scope };
321
+ if (sibling) {
322
+ return {
323
+ path: sibling.path,
324
+ concrete: sibling.concrete,
325
+ existingKeys: sibling.keys,
326
+ scope: sibling.scope,
327
+ };
328
+ }
275
329
 
276
330
  // Nest under the nearest-preceding key shallower than the cursor.
277
331
  let nest: PairScope | undefined;
@@ -286,9 +340,16 @@ function columnSearch(
286
340
  }
287
341
  }
288
342
  }
289
- if (nest) return { path: nest.path, existingKeys: nest.childKeys, scope: nest.scope };
343
+ if (nest) {
344
+ return {
345
+ path: nest.path,
346
+ concrete: nest.concrete,
347
+ existingKeys: nest.childKeys,
348
+ scope: nest.scope,
349
+ };
350
+ }
290
351
 
291
- return { path: [], existingKeys: new Set(), scope: { depth: 0 } };
352
+ return { path: [], concrete: "", existingKeys: new Set(), scope: { depth: 0 } };
292
353
  }
293
354
 
294
355
  // ---------------------------------------------------------------------------
@@ -326,7 +387,7 @@ export function resolveNodeAtPosition(
326
387
  const doc = docs[docIndex];
327
388
  const docKind = docKindOf(doc);
328
389
 
329
- const found = doc.root ? descend(doc.root, [], offset, { depth: 0 }) : undefined;
390
+ const found = doc.root ? descend(doc.root, [], "", offset, { depth: 0 }) : undefined;
330
391
 
331
392
  // Cursor sits on an existing map key → key/prop-key position.
332
393
  if (found?.type === "key") {
@@ -341,6 +402,7 @@ export function resolveNodeAtPosition(
341
402
  docKind,
342
403
  slot: "key",
343
404
  path: found.path,
405
+ concretePath: found.concretePath,
344
406
  node: found.keyNode,
345
407
  replaceRange: { start: toPos(found.keyNode.range[0]), end: toPos(found.keyNode.range[1]) },
346
408
  container: found.container,
@@ -365,13 +427,14 @@ export function resolveNodeAtPosition(
365
427
  toPos(value.range[0]).line !== toPos(found.keyEnd).line;
366
428
  if (isPartialKey && doc.root) {
367
429
  const col = toPos(value.range[0]).character;
368
- const { path, existingKeys, scope } = columnSearch(doc.root, col, offset, lineOffsets);
430
+ const { path, concrete, existingKeys, scope } = columnSearch(doc.root, col, offset, lineOffsets);
369
431
  return {
370
432
  docIndex,
371
433
  offset,
372
434
  docKind,
373
435
  slot: "key",
374
436
  path,
437
+ concretePath: concrete,
375
438
  container: found.container,
376
439
  existingKeys,
377
440
  resourceKind: scope.kind,
@@ -386,6 +449,7 @@ export function resolveNodeAtPosition(
386
449
  docKind,
387
450
  slot: "value",
388
451
  path: found.keyName != null ? [...found.path, found.keyName] : found.path,
452
+ concretePath: found.concretePath,
389
453
  node: value,
390
454
  container: found.container,
391
455
  prefix: text.slice(value.range[0], clampedEnd),
@@ -400,13 +464,14 @@ export function resolveNodeAtPosition(
400
464
  // resolved by cursor column.
401
465
  const resolution: KeyResolution = doc.root
402
466
  ? columnSearch(doc.root, character, offset, lineOffsets)
403
- : { path: [], existingKeys: new Set<string>(), scope: { depth: 0 } };
467
+ : { path: [], concrete: "", existingKeys: new Set<string>(), scope: { depth: 0 } };
404
468
  return {
405
469
  docIndex,
406
470
  offset,
407
471
  docKind,
408
472
  slot: "key",
409
473
  path: resolution.path,
474
+ concretePath: resolution.concrete,
410
475
  existingKeys: resolution.existingKeys,
411
476
  resourceKind: resolution.scope.kind,
412
477
  resourceDepth: resolution.scope.depth,
@@ -10,7 +10,7 @@ export const CAPABILITY_VALUES = [
10
10
  /** One-line role summary per capability, surfaced on hover. Kept in sync with
11
11
  * the capability list in `CLAUDE.md` / the kernel builtins. */
12
12
  export const CAPABILITY_DOCS: Record<string, string> = {
13
- "Telo.Service": "Long-lived resource: `init()` + optional `teardown()` (servers, pools).",
13
+ "Telo.Service": "Long-lived resource: `init()` + `run()`, returning what undoes them (servers, pools).",
14
14
  "Telo.Runnable": "One-shot task: `run()` (pipelines, boot steps).",
15
15
  "Telo.Invocable": "Request handler: `invoke(inputs)` (scripts, endpoints).",
16
16
  "Telo.Provider": "Value-flow source: `init()` + optional `provide()` (config, secrets).",
@@ -1,7 +1,15 @@
1
- import { parseToAst, type AstDocument, type AstScalar, type LoadedGraph } from "@telorun/analyzer";
1
+ import {
2
+ parseToAst,
3
+ type AstDocument,
4
+ type AstScalar,
5
+ type ManifestAnalysis,
6
+ type LoadedGraph,
7
+ } from "@telorun/analyzer";
2
8
  import type { DefinitionResult } from "../types.js";
3
9
  import { resolveNodeAtPosition } from "../completions/resolve-node.js";
4
10
  import { splitAliasQualified } from "./alias-qualified-value.js";
11
+ import { locateContextBinding } from "./locate-context-binding.js";
12
+ import { locateStepDeclaration } from "./locate-step.js";
5
13
  import { moduleForFile } from "./manifest-navigation.js";
6
14
  import { resolveCelTarget } from "./resolve-cel-target.js";
7
15
  import { isKindSlot, resolveKindTarget } from "./resolve-kind-target.js";
@@ -30,6 +38,10 @@ export function buildDefinition(
30
38
  graph: LoadedGraph,
31
39
  currentFilePath: string,
32
40
  docs?: AstDocument[],
41
+ /** Lets a `steps.<name>` read navigate to the step that produced it, and a
42
+ * context binding to its declaration — both need the declaring kind's own
43
+ * annotations, which only the analysis can read. */
44
+ analysis?: ManifestAnalysis,
33
45
  ): DefinitionResult | undefined {
34
46
  const astDocs = docs ?? parseToAst(text);
35
47
  const resolved = resolveNodeAtPosition(text, astDocs, line, character);
@@ -38,7 +50,23 @@ export function buildDefinition(
38
50
  const currentModule = moduleForFile(graph, currentFilePath) ?? graph.entry;
39
51
 
40
52
  if (resolved.cel) {
41
- return resolveCelTarget(graph, currentModule, resolved.cel.segment, resolved.cel.offset);
53
+ return resolveCelTarget(graph, currentModule, resolved.cel.segment, resolved.cel.offset, {
54
+ // A step is resolved in the CURRENT document — `steps.<name>.result` is
55
+ // readable only inside the resource that declares it, which is one
56
+ // document. Supplied as a closure so the chain resolver stays free of the
57
+ // AST and the scope query alike.
58
+ locateStep: (stepName) =>
59
+ locateStepDeclaration(graph, currentFilePath, astDocs, resolved.docIndex, stepName, analysis?.celScope),
60
+ locateContextBinding: (parts) =>
61
+ locateContextBinding(
62
+ graph,
63
+ astDocs,
64
+ resolved.docIndex,
65
+ resolved.concretePath ?? "",
66
+ parts,
67
+ analysis?.celScope,
68
+ ),
69
+ });
42
70
  }
43
71
 
44
72
  const node = resolved.node;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Where a CEL context binding was declared.
3
+ *
4
+ * `request.query` is written in the route's own `request.schema`, `self.<field>`
5
+ * in the definition's `schema`, `result.<field>` in the INVOKED resource's
6
+ * `outputType` — each derived by an `x-telo-context-*` annotation rather than
7
+ * reached through a reference slot, so none of them is navigable by the graph
8
+ * walk the other CEL roots use. The scope query resolves the annotation to a
9
+ * manifest identity plus a path; locating that path in the loaded files is what
10
+ * this adds.
11
+ */
12
+ import type { AstDocument, CelScopeQuery, LoadedFile, LoadedGraph } from "@telorun/analyzer";
13
+ import type { DefinitionResult } from "../types.js";
14
+ import { docIdentity } from "../doc-identity.js";
15
+
16
+ /** Every file of every module in the graph — a binding's declaration may sit in
17
+ * another module (an invoked handler's `outputType`), so the search is not
18
+ * confined to the current file. */
19
+ function allFiles(graph: LoadedGraph): LoadedFile[] {
20
+ const out: LoadedFile[] = [];
21
+ for (const mod of graph.modules.values()) out.push(mod.owner, ...mod.partials);
22
+ return out;
23
+ }
24
+
25
+ export function locateContextBinding(
26
+ graph: LoadedGraph,
27
+ docs: AstDocument[],
28
+ docIndex: number,
29
+ sitePath: string,
30
+ parts: string[],
31
+ scopeQuery: CelScopeQuery | undefined,
32
+ ): DefinitionResult | undefined {
33
+ if (!scopeQuery) return undefined;
34
+ const identity = docIdentity(docs[docIndex]);
35
+ const resource = scopeQuery.resourceFor(identity.kind, identity.name);
36
+ if (!resource) return undefined;
37
+
38
+ const site = scopeQuery.contextDeclarationSite(resource, sitePath, parts);
39
+ if (!site) return undefined;
40
+
41
+ for (const file of allFiles(graph)) {
42
+ for (let i = 0; i < file.manifests.length; i++) {
43
+ const manifest = file.manifests[i] as { kind?: string; metadata?: { name?: string } } | null;
44
+ if (manifest?.kind !== site.kind || manifest.metadata?.name !== site.name) continue;
45
+ const index = file.positions[i]?.positionIndex;
46
+ // The KEY span, so the jump underlines `query:` rather than the block that
47
+ // follows it — a declaration, not a selection.
48
+ const range = index?.get(`@key:${site.path}`) ?? index?.get(site.path);
49
+ if (range) return { uri: file.source, range };
50
+ }
51
+ }
52
+ return undefined;
53
+ }