@telorun/ide-support 0.15.0 → 0.16.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 (69) 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/definition/build-definition.d.ts +6 -2
  29. package/dist/definition/build-definition.d.ts.map +1 -1
  30. package/dist/definition/build-definition.js +16 -3
  31. package/dist/definition/locate-context-binding.d.ts +15 -0
  32. package/dist/definition/locate-context-binding.d.ts.map +1 -0
  33. package/dist/definition/locate-context-binding.js +35 -0
  34. package/dist/definition/locate-step.d.ts +13 -0
  35. package/dist/definition/locate-step.d.ts.map +1 -0
  36. package/dist/definition/locate-step.js +33 -0
  37. package/dist/definition/resolve-cel-target.d.ts +11 -1
  38. package/dist/definition/resolve-cel-target.d.ts.map +1 -1
  39. package/dist/definition/resolve-cel-target.js +14 -14
  40. package/dist/doc-identity.d.ts +17 -0
  41. package/dist/doc-identity.d.ts.map +1 -0
  42. package/dist/doc-identity.js +19 -0
  43. package/dist/hover/build-hover.d.ts +6 -2
  44. package/dist/hover/build-hover.d.ts.map +1 -1
  45. package/dist/hover/build-hover.js +64 -3
  46. package/dist/semantic-tokens/build-semantic-tokens.d.ts +13 -8
  47. package/dist/semantic-tokens/build-semantic-tokens.d.ts.map +1 -1
  48. package/dist/semantic-tokens/build-semantic-tokens.js +81 -37
  49. package/dist/types.d.ts +25 -5
  50. package/dist/types.d.ts.map +1 -1
  51. package/dist/types.js +16 -2
  52. package/package.json +2 -2
  53. package/src/cel/cursor-chain.ts +58 -0
  54. package/src/cel/symbols.ts +189 -0
  55. package/src/cel/tokens.ts +169 -0
  56. package/src/completions/build.ts +85 -22
  57. package/src/completions/call-inputs.ts +92 -0
  58. package/src/completions/cel-completions.ts +108 -0
  59. package/src/completions/detect-context.ts +107 -13
  60. package/src/completions/prop-keys.ts +59 -2
  61. package/src/completions/resolve-node.ts +82 -17
  62. package/src/definition/build-definition.ts +30 -2
  63. package/src/definition/locate-context-binding.ts +53 -0
  64. package/src/definition/locate-step.ts +50 -0
  65. package/src/definition/resolve-cel-target.ts +25 -0
  66. package/src/doc-identity.ts +31 -0
  67. package/src/hover/build-hover.ts +67 -1
  68. package/src/semantic-tokens/build-semantic-tokens.ts +84 -30
  69. package/src/types.ts +47 -6
@@ -1,7 +1,16 @@
1
- import { parseToAst, type AnalysisRegistry, type AstDocument, type AstMap } from "@telorun/analyzer";
1
+ import {
2
+ parseToAst,
3
+ type AnalysisRegistry,
4
+ type AstDocument,
5
+ type AstMap,
6
+ type ManifestAnalysis,
7
+ } from "@telorun/analyzer";
2
8
  import type { CompletionResult, IdeEnvironmentAdapter } from "../types.js";
3
9
  import type { ReplaceRange } from "./detect-context.js";
4
- import { detectContext, lookupRefConstraints } from "./detect-context.js";
10
+ import { callInputsAt } from "./call-inputs.js";
11
+ import { celCompletions } from "./cel-completions.js";
12
+ import { docIdentity } from "../doc-identity.js";
13
+ import { detectContext, lookupRefConstraints, navigateSchema } from "./detect-context.js";
5
14
  import { importSourceCompletions } from "./import-source.js";
6
15
  import { propKeyCompletions } from "./prop-keys.js";
7
16
  import { CAPABILITY_VALUES } from "./valid-capabilities.js";
@@ -16,23 +25,9 @@ interface ResourceRecord {
16
25
  * either is simply skipped; the analyzer remains the source of truth. */
17
26
  function extractInFileResources(docs: AstDocument[]): ResourceRecord[] {
18
27
  const out: ResourceRecord[] = [];
19
- const scalar = (node: { kind: string; value?: unknown } | undefined): string | undefined =>
20
- node?.kind === "scalar" && typeof node.value === "string" ? node.value : undefined;
21
-
22
28
  for (const doc of docs) {
23
- if (doc.root?.kind !== "map") continue;
24
- let kind: string | undefined;
25
- let name: string | undefined;
26
- for (const pair of doc.root.entries) {
27
- const key = scalar(pair.key);
28
- if (key === "kind") kind = scalar(pair.value);
29
- else if (key === "metadata" && pair.value?.kind === "map") {
30
- const meta = pair.value as AstMap;
31
- const nameEntry = meta.entries.find((e) => scalar(e.key) === "name");
32
- name = scalar(nameEntry?.value);
33
- }
34
- }
35
- if (kind && name) out.push({ kind, name });
29
+ const identity = docIdentity(doc);
30
+ if (identity.kind && identity.name) out.push({ kind: identity.kind, name: identity.name });
36
31
  }
37
32
  return out;
38
33
  }
@@ -98,6 +93,7 @@ function refConstrainedKinds(
98
93
  const constraints = lookupRefConstraints(
99
94
  definition.schema as Record<string, any>,
100
95
  parentYamlPath,
96
+ (from) => registry.resolveSchemaFrom(from, parentDocKind),
101
97
  );
102
98
  if (constraints.length === 0) return undefined;
103
99
  const resolved = constraints.map((c) => registry.userFacingKindsForRef(c));
@@ -133,6 +129,39 @@ function kindCompletions(
133
129
  return results;
134
130
  }
135
131
 
132
+ /**
133
+ * The values a field's schema says it may take.
134
+ *
135
+ * `enum` is closed and `examples` open — the same distinction `propertyNames`
136
+ * carries for a map's keys, one level down. Nothing is offered when the schema
137
+ * declares neither, which is most slots.
138
+ */
139
+ function valueSuggestions(
140
+ registry: AnalysisRegistry | undefined,
141
+ docKind: string,
142
+ yamlPath: string[],
143
+ replaceRange: ReplaceRange,
144
+ ): CompletionResult[] {
145
+ const definition = registry?.resolveDefinition(docKind);
146
+ if (!registry || !definition?.schema || yamlPath.length === 0) return [];
147
+ const field = navigateSchema(definition.schema as Record<string, any>, yamlPath, (from) =>
148
+ registry.resolveSchemaFrom(from, docKind),
149
+ );
150
+ if (!field) return [];
151
+ const closed = Array.isArray(field.enum) ? (field.enum as unknown[]) : undefined;
152
+ const values = closed ?? (Array.isArray(field.examples) ? (field.examples as unknown[]) : []);
153
+ return values
154
+ .filter((v) => v !== null && typeof v !== "object")
155
+ .map((value) => ({
156
+ label: String(value),
157
+ kind: "enumMember" as const,
158
+ detail: closed ? "allowed value" : "known value",
159
+ // Whole-value replacement, so picking over a partially typed value leaves
160
+ // no suffix — the rule every other value completion here follows.
161
+ replaceRange,
162
+ }));
163
+ }
164
+
136
165
  function capabilityCompletions(): CompletionResult[] {
137
166
  return CAPABILITY_VALUES.map((cap) => ({
138
167
  label: cap,
@@ -148,6 +177,10 @@ export async function buildCompletions(
148
177
  registry: AnalysisRegistry | undefined,
149
178
  adapter?: IdeEnvironmentAdapter,
150
179
  docs?: AstDocument[],
180
+ /** The host's analysis of the manifests it loaded. Required for anything
181
+ * that has to resolve against the manifest SET — CEL completion, and a
182
+ * target's declared inputs. */
183
+ analysis?: ManifestAnalysis,
151
184
  ): Promise<CompletionResult[]> {
152
185
  // Reuse the host's already-parsed AST when it matches the current buffer;
153
186
  // otherwise parse once here (Part 1 stands alone). Both `detectContext` and
@@ -159,11 +192,27 @@ export async function buildCompletions(
159
192
  return kindCompletions(registry, ctx.docKind, ctx.yamlPath, ctx.replaceRange);
160
193
  }
161
194
  if (ctx.type === "capability") return capabilityCompletions();
195
+ if (ctx.type === "value-suggestions") {
196
+ return valueSuggestions(registry, ctx.docKind, ctx.yamlPath, ctx.replaceRange);
197
+ }
198
+ if (ctx.type === "cel") {
199
+ return celCompletions(
200
+ text,
201
+ ctx.segment,
202
+ ctx.offset,
203
+ ctx.concretePath,
204
+ docIdentity(astDocs[ctx.docIndex]),
205
+ analysis?.celScope,
206
+ );
207
+ }
162
208
  if (ctx.type === "ref-name") {
163
209
  const definition = registry?.resolveDefinition(ctx.docKind);
164
- const refConstraints = definition?.schema
165
- ? lookupRefConstraints(definition.schema as Record<string, any>, ctx.yamlPath)
166
- : [];
210
+ const refConstraints =
211
+ registry && definition?.schema
212
+ ? lookupRefConstraints(definition.schema as Record<string, any>, ctx.yamlPath, (from) =>
213
+ registry.resolveSchemaFrom(from, ctx.docKind),
214
+ )
215
+ : [];
167
216
  return refNameCompletions(astDocs, ctx.refKind, refConstraints, registry, ctx.replaceRange);
168
217
  }
169
218
  if (ctx.type === "field-value") {
@@ -172,5 +221,19 @@ export async function buildCompletions(
172
221
  }
173
222
  return [];
174
223
  }
175
- return propKeyCompletions(ctx.docKind, ctx.yamlPath, ctx.existingKeys, registry);
224
+ // A slot that IS an enclosing call's argument map completes from the target's
225
+ // declared inputs rather than from its own (open) schema.
226
+ return propKeyCompletions(
227
+ ctx.docKind,
228
+ ctx.yamlPath,
229
+ ctx.existingKeys,
230
+ registry,
231
+ callInputsAt(
232
+ registry,
233
+ analysis,
234
+ docIdentity(astDocs[ctx.docIndex]).kind ?? ctx.docKind,
235
+ docIdentity(astDocs[ctx.docIndex]).name,
236
+ ctx.concretePath,
237
+ ),
238
+ );
176
239
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The arguments a call site declares.
3
+ *
4
+ * A slot that transfers control names its argument slot on its own `x-telo-ref`
5
+ * — `inputs: /inputs`, a JSON Pointer relative to the object enclosing the slot.
6
+ * That is the only thing tying an `inputs:` map to the resource it is arguments
7
+ * FOR: the map itself is an open object, and the reference sits in a sibling
8
+ * field whose name no walker may assume.
9
+ *
10
+ * Reading the pointer here means completion offers exactly the keys the invoked
11
+ * target declares — resolved through the shared contract resolver, so they are
12
+ * the keys `telo check` validates that call against and the kernel binds at
13
+ * dispatch, instance declaration first.
14
+ */
15
+ import {
16
+ navigateConcretePath,
17
+ readRefSlot,
18
+ type AnalysisRegistry,
19
+ type ManifestAnalysis,
20
+ type ManifestRef,
21
+ } from "@telorun/analyzer";
22
+ import { navigateSchema } from "./detect-context.js";
23
+
24
+ /** Resolve a JSON Pointer that is a plain property path (`/inputs`) into path
25
+ * segments. Pointers here address a sibling FIELD, never an array element, so
26
+ * anything else is left alone rather than guessed at. */
27
+ function pointerSegments(pointer: string): string[] | undefined {
28
+ if (!pointer.startsWith("/")) return undefined;
29
+ const segments = pointer
30
+ .slice(1)
31
+ .split("/")
32
+ .map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
33
+ return segments.every((s) => s.length > 0 && !/^\d+$/.test(s)) ? segments : undefined;
34
+ }
35
+
36
+ /**
37
+ * The declared input contract of the call whose argument slot is at
38
+ * `concretePath`, or undefined when this path is not one.
39
+ *
40
+ * Both halves have to line up: the enclosing object's schema must declare a ref
41
+ * slot whose `inputs` pointer names this field, and the manifest must fill that
42
+ * ref. Either missing means there is no call here to take arguments for.
43
+ */
44
+ export function callInputsAt(
45
+ registry: AnalysisRegistry | undefined,
46
+ analysis: ManifestAnalysis | undefined,
47
+ docKind: string,
48
+ resourceName: string | undefined,
49
+ concretePath: string,
50
+ ): Record<string, any> | undefined {
51
+ if (!registry || !analysis || !concretePath) return undefined;
52
+ const resource = analysis.resourceFor(docKind, resourceName);
53
+ if (!resource) return undefined;
54
+ const definition = registry.resolveDefinition(docKind);
55
+ if (!definition?.schema) return undefined;
56
+
57
+ const segments = concretePath.split(".").map((seg) => seg.replace(/\[\d+\]$/, ""));
58
+
59
+ // The pointer is relative to the object ENCLOSING the annotated slot, and it
60
+ // may name a nested field (`/handler/inputs`), so every prefix of this path is
61
+ // a candidate enclosing object — not just the immediate parent. Trying them
62
+ // longest-first keeps the nearest enclosing declaration winning.
63
+ const concreteSegments = concretePath.split(".");
64
+ for (let depth = segments.length - 1; depth >= 0; depth--) {
65
+ const enclosing = segments.slice(0, depth);
66
+ const tail = segments.slice(depth);
67
+ const enclosingSchema = navigateSchema(
68
+ definition.schema as Record<string, any>,
69
+ // Schema navigation is index-free; the concrete path is not.
70
+ enclosing.filter(Boolean),
71
+ (from) => registry.resolveSchemaFrom(from, docKind),
72
+ );
73
+ const properties = enclosingSchema?.properties as Record<string, any> | undefined;
74
+ if (!properties) continue;
75
+
76
+ for (const [siblingName, siblingSchema] of Object.entries(properties)) {
77
+ const slot = readRefSlot(siblingSchema);
78
+ if (!slot?.inputs) continue;
79
+ const pointed = pointerSegments(slot.inputs);
80
+ if (!pointed || pointed.length !== tail.length) continue;
81
+ if (!pointed.every((seg, i) => seg === tail[i])) continue;
82
+
83
+ const refPath = [...concreteSegments.slice(0, depth), siblingName].filter(Boolean).join(".");
84
+ const ref = navigateConcretePath(resource as Record<string, any>, refPath) as
85
+ | ManifestRef
86
+ | undefined;
87
+ if (!ref || typeof ref !== "object" || !ref.name) continue;
88
+ return analysis.contractFor(ref, "inputType");
89
+ }
90
+ }
91
+ return undefined;
92
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Completion inside a CEL body.
3
+ *
4
+ * Every candidate comes from the scope the analyzer resolved for this exact
5
+ * site, so the list is a claim that what it offers will type-check — not a
6
+ * separate model of what CEL sees. Where the scope declares nothing (an open
7
+ * node, a permissive contract, a live value) nothing is offered, which is the
8
+ * honest answer rather than a guess.
9
+ */
10
+ import type { CelScopeQuery, CelSegment } from "@telorun/analyzer";
11
+ import type { CompletionResult, ReplaceRange } from "../types.js";
12
+ import { celCursorChain } from "../cel/cursor-chain.js";
13
+ import { celFunctions, celMemberSymbols, celRootSymbols, type CelSymbol } from "../cel/symbols.js";
14
+
15
+ /** The resource a cursor's document addresses. */
16
+ export interface CelCompletionTarget {
17
+ kind?: string;
18
+ name?: string;
19
+ }
20
+
21
+ /** Markdown listing every overload, with the function's own description above
22
+ * it. A single-signature function needs no list — its one signature is already
23
+ * on the detail line. */
24
+ function describeOverloads(signatures: string[], description?: string): string | undefined {
25
+ if (signatures.length <= 1) return description;
26
+ const list = signatures.map((s) => `- \`${s}\``).join("\n");
27
+ return description ? `${description}\n\n${list}` : list;
28
+ }
29
+
30
+ function toResult(symbol: CelSymbol, replaceRange?: ReplaceRange): CompletionResult {
31
+ return {
32
+ label: symbol.name,
33
+ kind: "property",
34
+ detail: symbol.type,
35
+ documentation: symbol.description,
36
+ replaceRange,
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Candidates for the cursor inside `segment`.
42
+ *
43
+ * A member position (`req.|`) offers only what the prefix declares — no
44
+ * functions, since a receiver-style call is rare next to a field access and
45
+ * mixing them buries the fields. A root position offers the scope's names
46
+ * first, then the global functions the environment declares.
47
+ */
48
+ export function celCompletions(
49
+ text: string,
50
+ segment: CelSegment,
51
+ offset: number,
52
+ concretePath: string,
53
+ target: CelCompletionTarget,
54
+ query: CelScopeQuery | undefined,
55
+ ): CompletionResult[] {
56
+ if (!query) return [];
57
+ const resource = query.resourceFor(target.kind, target.name);
58
+ if (!resource) return [];
59
+ const scope = query.scopeAt(resource, concretePath);
60
+
61
+ const chain = celCursorChain(text, segment, offset);
62
+ const prefix = chain?.prefix ?? [];
63
+
64
+ if (chain?.member) {
65
+ return celMemberSymbols(scope, prefix).map((s) => toResult(s));
66
+ }
67
+
68
+ const roots = celRootSymbols(scope);
69
+ const results = roots.map((s) => toResult(s));
70
+ // A CEL type name (`double`, `int`, `string`) is registered as a variable of
71
+ // type `type` AND as the conversion function of the same name. Both are real,
72
+ // but two identical labels are two things an author cannot choose between, so
73
+ // the callable form wins the slot and says it also names the type — that is
74
+ // the form written at a root position.
75
+ const typeNames = new Set(roots.filter((s) => s.type === "type").map((s) => s.name));
76
+
77
+ // Global functions only: a receiver-style one (`string.startsWith`) is
78
+ // reachable through a member position, where the receiver's type is known.
79
+ for (const fn of celFunctions(scope)) {
80
+ if (fn.receiverType) continue;
81
+ const alsoAType = typeNames.has(fn.name);
82
+ if (alsoAType) {
83
+ const at = results.findIndex((r) => r.label === fn.name);
84
+ if (at >= 0) results.splice(at, 1);
85
+ }
86
+ results.push({
87
+ label: fn.name,
88
+ kind: "value",
89
+ // One candidate per function, so the extra overloads are reported IN it
90
+ // rather than as repeated labels: the count on the detail line, the
91
+ // signatures themselves in the documentation.
92
+ detail:
93
+ fn.signatures.length > 1
94
+ ? `${fn.signatures[0]} (+${fn.signatures.length - 1} overloads)`
95
+ : fn.signatures[0],
96
+ documentation: describeOverloads(
97
+ fn.signatures,
98
+ alsoAType
99
+ ? [fn.description, `Also names the CEL type \`${fn.name}\`.`].filter(Boolean).join("\n\n")
100
+ : fn.description,
101
+ ),
102
+ // Sorted after the scope's own names: a variable is what an author is
103
+ // reaching for at a root position far more often than a built-in.
104
+ sortText: `z${fn.name}`,
105
+ });
106
+ }
107
+ return results;
108
+ }
@@ -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
  /**