@telorun/ide-support 0.14.1 → 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 (85) 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/import-upgrades/build-import-upgrades.d.ts +57 -3
  47. package/dist/import-upgrades/build-import-upgrades.d.ts.map +1 -1
  48. package/dist/import-upgrades/build-import-upgrades.js +67 -10
  49. package/dist/import-upgrades/index.d.ts +5 -2
  50. package/dist/import-upgrades/index.d.ts.map +1 -1
  51. package/dist/import-upgrades/index.js +3 -1
  52. package/dist/import-upgrades/manifest-cache-url.d.ts +17 -0
  53. package/dist/import-upgrades/manifest-cache-url.d.ts.map +1 -0
  54. package/dist/import-upgrades/manifest-cache-url.js +30 -0
  55. package/dist/import-upgrades/version-compatibility.d.ts +92 -0
  56. package/dist/import-upgrades/version-compatibility.d.ts.map +1 -0
  57. package/dist/import-upgrades/version-compatibility.js +121 -0
  58. package/dist/semantic-tokens/build-semantic-tokens.d.ts +13 -8
  59. package/dist/semantic-tokens/build-semantic-tokens.d.ts.map +1 -1
  60. package/dist/semantic-tokens/build-semantic-tokens.js +81 -37
  61. package/dist/types.d.ts +25 -5
  62. package/dist/types.d.ts.map +1 -1
  63. package/dist/types.js +16 -2
  64. package/package.json +2 -2
  65. package/src/cel/cursor-chain.ts +58 -0
  66. package/src/cel/symbols.ts +189 -0
  67. package/src/cel/tokens.ts +169 -0
  68. package/src/completions/build.ts +85 -22
  69. package/src/completions/call-inputs.ts +92 -0
  70. package/src/completions/cel-completions.ts +108 -0
  71. package/src/completions/detect-context.ts +107 -13
  72. package/src/completions/prop-keys.ts +59 -2
  73. package/src/completions/resolve-node.ts +82 -17
  74. package/src/definition/build-definition.ts +30 -2
  75. package/src/definition/locate-context-binding.ts +53 -0
  76. package/src/definition/locate-step.ts +50 -0
  77. package/src/definition/resolve-cel-target.ts +25 -0
  78. package/src/doc-identity.ts +31 -0
  79. package/src/hover/build-hover.ts +67 -1
  80. package/src/import-upgrades/build-import-upgrades.ts +115 -11
  81. package/src/import-upgrades/index.ts +22 -1
  82. package/src/import-upgrades/manifest-cache-url.ts +40 -0
  83. package/src/import-upgrades/version-compatibility.ts +196 -0
  84. package/src/semantic-tokens/build-semantic-tokens.ts +84 -30
  85. package/src/types.ts +47 -6
@@ -1,4 +1,5 @@
1
1
  import {
2
+ TELO_SURFACE_VERSION,
2
3
  buildLineOffsets,
3
4
  foldIntegrity,
4
5
  isCanonicalIntegrity,
@@ -14,6 +15,11 @@ import {
14
15
  type Range,
15
16
  } from "@telorun/analyzer";
16
17
  import { findImportEntries, type ImportEntry } from "./find-import-entries.js";
18
+ import {
19
+ selectCompatibleVersion,
20
+ type IncompatibilityReason,
21
+ type VersionCompatibilityCheck,
22
+ } from "./version-compatibility.js";
17
23
 
18
24
  /** One version of a module, as the hub reports it.
19
25
  *
@@ -41,6 +47,23 @@ export interface ModuleVersion {
41
47
  * {@link parseModuleVersions}. */
42
48
  export type ModuleVersionLookup = (baseRef: string) => Promise<ModuleVersion[]>;
43
49
 
50
+ /** Everything an upgrade needs from its host: what versions exist, and whether
51
+ * this runtime can host one.
52
+ *
53
+ * `isCompatible` is required rather than optional so no host can quietly skip
54
+ * the check and walk an author onto a version their telo cannot load — the gap
55
+ * this package existed with. A host that genuinely cannot fetch candidate
56
+ * manifests passes {@link uncheckedVersionCompatibility}, which says so
57
+ * instead of pretending. */
58
+ export interface ImportUpgradeEnvironment {
59
+ listVersions: ModuleVersionLookup;
60
+ isCompatible: VersionCompatibilityCheck;
61
+ /** Offer prereleases as automatic upgrade targets. Off by default, matching
62
+ * `telo upgrade`: an upgrade nobody asked for must not walk a caller onto an
63
+ * `-rc` build. */
64
+ includePrerelease?: boolean;
65
+ }
66
+
44
67
  /** A source edit a host applies verbatim to upgrade an import. Ranges never
45
68
  * overlap, within an upgrade or across a batch, so a host may apply the whole
46
69
  * set in one pass without ordering them. */
@@ -69,6 +92,11 @@ export interface ImportUpgrade {
69
92
  * means no pin was available for the target version — a host should say so
70
93
  * when `wasPinned`, since the rewrite silently drops a hash the author had. */
71
94
  repinned: boolean;
95
+ /** A newer version that exists but this runtime cannot host, when
96
+ * `latestVersion` is not the newest published. Surfacing it is not optional:
97
+ * an upgrade that quietly stops short of the newest version, with no reason
98
+ * given, reads as a bug in the tooling. */
99
+ heldBack?: { version: string; reason: IncompatibilityReason };
72
100
  /** Span of the alias key — where a per-entry affordance anchors. */
73
101
  keyRange: Range;
74
102
  /** Apply all of these to upgrade this one import. */
@@ -100,8 +128,18 @@ export interface ImportUpgradeSkip {
100
128
  latestVersion: string;
101
129
  /** Span of the alias key — where a per-entry affordance anchors. */
102
130
  keyRange: Range;
103
- /** Author-facing sentence: what was not done, and what to run instead. */
104
- reason: string;
131
+ /** Which decision this was, for a host that styles or filters them.
132
+ * `incompatible` — every newer version declares a telo this runtime is not;
133
+ * `stale-inline-pin` — the rewrite could not carry or drop the author's pin. */
134
+ code: "incompatible" | "stale-inline-pin";
135
+ /** Which rejection produced an `incompatible` skip, so a host can phrase its
136
+ * own affordance without re-deriving one. Carried BESIDE `code` because the
137
+ * code says what was not done while this says why, and the remedies differ:
138
+ * updating telo cannot fix a requirement the module failed to state. Absent
139
+ * for `stale-inline-pin`, which is not a compatibility decision at all. */
140
+ reason?: IncompatibilityReason;
141
+ /** Author-facing sentence: what was not done, and what to do instead. */
142
+ message: string;
105
143
  }
106
144
 
107
145
  export interface ImportUpgradeSet {
@@ -122,6 +160,13 @@ export interface ImportUpgradeSet {
122
160
  * re-point it — plus every entry already at the newest version that carries no
123
161
  * integrity pin, and the edits that pin it.
124
162
  *
163
+ * The target is the newest version this runtime can HOST, which is not always
164
+ * the newest one published: each candidate's declared `requires.telo` is checked
165
+ * newest-first, and a newer version that was held back is reported on the
166
+ * upgrade rather than dropped. An import whose every newer version needs a newer
167
+ * telo produces a `skipped` entry instead, since the alternative — offering it
168
+ * anyway — is a manifest that fails at the load gate.
169
+ *
125
170
  * Skips what carries no upgradeable version: local path imports, bare URLs,
126
171
  * untagged refs, and pins that are not SemVer (an OCI digest, a moving tag like
127
172
  * `latest`) — `parseVersionedRef` and `isNewerModuleVersion` both decline to
@@ -133,7 +178,7 @@ export interface ImportUpgradeSet {
133
178
  */
134
179
  export async function buildImportUpgrades(
135
180
  text: string,
136
- listVersions: ModuleVersionLookup,
181
+ env: ImportUpgradeEnvironment,
137
182
  docs?: AstDocument[],
138
183
  ): Promise<ImportUpgradeSet | undefined> {
139
184
  const lineOffsets = buildLineOffsets(text);
@@ -149,7 +194,7 @@ export async function buildImportUpgrades(
149
194
  const failures: Array<{ baseRef: string; message: string }> = [];
150
195
  const known = await resolveVersions(
151
196
  [...new Set(candidates.map((c) => c.ref.baseRef))],
152
- listVersions,
197
+ env.listVersions,
153
198
  failures,
154
199
  );
155
200
 
@@ -160,7 +205,9 @@ export async function buildImportUpgrades(
160
205
  for (const { entry, ref } of candidates) {
161
206
  const versions = known.get(ref.baseRef);
162
207
  if (!versions) continue;
163
- const newest = newestModuleVersion(versions.map((v) => v.version));
208
+ const newest = newestModuleVersion(versions.map((v) => v.version), {
209
+ includePrerelease: env.includePrerelease,
210
+ });
164
211
  if (!newest) continue;
165
212
 
166
213
  if (!isNewerModuleVersion(newest, ref.version)) {
@@ -169,7 +216,40 @@ export async function buildImportUpgrades(
169
216
  continue;
170
217
  }
171
218
 
172
- const integrity = integrityFor(versions, newest);
219
+ // The target is the newest version this runtime can actually host, not
220
+ // simply the newest one published. An upgrade the author's telo cannot load
221
+ // is not an upgrade — it is a load failure they were walked into.
222
+ const { best, heldBack } = await selectCompatibleVersion(
223
+ ref.baseRef,
224
+ versions,
225
+ ref.version,
226
+ env.isCompatible,
227
+ { includePrerelease: env.includePrerelease },
228
+ );
229
+
230
+ // `heldBack` is non-null whenever a candidate existed, which `newest` being
231
+ // newer than the current version already established. Narrowed rather than
232
+ // defaulted: a reason nothing established is exactly what the four-verdict
233
+ // split exists to keep out of a message.
234
+ if (!best) {
235
+ if (!heldBack) continue;
236
+ skipped.push({
237
+ alias: entry.alias,
238
+ currentVersion: ref.version,
239
+ latestVersion: newest,
240
+ keyRange: entry.keyRange,
241
+ code: "incompatible",
242
+ reason: heldBack.reason,
243
+ message:
244
+ `'${entry.alias}' has newer versions (up to ${newest}), but none runs on telo ` +
245
+ `${TELO_SURFACE_VERSION} — ${describeReason(heldBack.reason)}. ` +
246
+ describeRemedy(heldBack.reason),
247
+ });
248
+ continue;
249
+ }
250
+
251
+ const target = best.version;
252
+ const integrity = integrityFor(versions, target);
173
253
  // A stale pin that can be neither replaced nor removed is the one case left
174
254
  // that has to be declined: re-pointing the source while leaving a hash for
175
255
  // the version it replaced turns the next install into a tamper error.
@@ -177,26 +257,28 @@ export async function buildImportUpgrades(
177
257
  skipped.push({
178
258
  alias: entry.alias,
179
259
  currentVersion: ref.version,
180
- latestVersion: newest,
260
+ latestVersion: target,
181
261
  keyRange: entry.keyRange,
182
- reason:
262
+ code: "stale-inline-pin",
263
+ message:
183
264
  `'${entry.alias}' carries an inline 'integrity:' that shares a line with other ` +
184
- `fields, and no pin is published for ${newest}, so the stale one cannot be ` +
265
+ `fields, and no pin is published for ${target}, so the stale one cannot be ` +
185
266
  `removed by a line edit. Run \`telo upgrade\`.`,
186
267
  });
187
268
  continue;
188
269
  }
189
270
 
190
- const newSource = withRefVersion(entry.source, newest);
271
+ const newSource = withRefVersion(entry.source, target);
191
272
  upgrades.push({
192
273
  alias: entry.alias,
193
274
  source: entry.source,
194
275
  currentVersion: ref.version,
195
- latestVersion: newest,
276
+ latestVersion: target,
196
277
  newSource: foldIntegrity(newSource, integrity),
197
278
  wasPinned: ref.integrity != null,
198
279
  repinned: integrity != null,
199
280
  keyRange: entry.keyRange,
281
+ ...(heldBack ? { heldBack } : {}),
200
282
  edits: buildEdits(entry, newSource, integrity),
201
283
  });
202
284
  }
@@ -204,6 +286,28 @@ export async function buildImportUpgrades(
204
286
  return { importsKeyRange: block.keyRange, upgrades, pins, skipped, failures };
205
287
  }
206
288
 
289
+ /** How a held-back version reads in a sentence, with the version itself as the
290
+ * implied subject. Names the cause the check actually established.
291
+ *
292
+ * The parameter is REQUIRED, so a caller cannot reach a sentence for a cause
293
+ * nothing established — defaulting an absent reason to "requires a newer telo"
294
+ * is precisely the invented verdict the four-way split exists to prevent. */
295
+ export function describeReason(reason: IncompatibilityReason): string {
296
+ return reason === "unreadable"
297
+ ? "its declared requirement cannot be read"
298
+ : "it requires a newer telo";
299
+ }
300
+
301
+ /** What the author can do about it. Split from {@link describeReason} because
302
+ * the two rejections have DIFFERENT remedies: an unreadable requirement is not
303
+ * a version skew, so telling the author to update telo would send them after a
304
+ * fix that cannot work — only the module's own author can repair it. */
305
+ export function describeRemedy(reason: IncompatibilityReason): string {
306
+ return reason === "unreadable"
307
+ ? "Only the module's author can fix that."
308
+ : "Update telo to upgrade it.";
309
+ }
310
+
207
311
  /** The edits that re-point an entry at `newSource` and settle its pin.
208
312
  *
209
313
  * Where the pin is written follows the shape the author chose: an entry with an
@@ -1,13 +1,19 @@
1
- export { buildImportUpgrades } from "./build-import-upgrades.js";
1
+ export {
2
+ buildImportUpgrades,
3
+ describeReason,
4
+ describeRemedy,
5
+ } from "./build-import-upgrades.js";
2
6
  export type {
3
7
  ImportPin,
4
8
  ImportUpgrade,
5
9
  ImportUpgradeEdit,
10
+ ImportUpgradeEnvironment,
6
11
  ImportUpgradeSet,
7
12
  ImportUpgradeSkip,
8
13
  ModuleVersion,
9
14
  ModuleVersionLookup,
10
15
  } from "./build-import-upgrades.js";
16
+ export { moduleManifestCacheUrl } from "./manifest-cache-url.js";
11
17
  export { parseModuleVersions } from "./parse-module-versions.js";
12
18
  export { findImportEntries } from "./find-import-entries.js";
13
19
  export type {
@@ -15,3 +21,18 @@ export type {
15
21
  ImportEntryIntegrity,
16
22
  ImportsBlock,
17
23
  } from "./find-import-entries.js";
24
+ export {
25
+ createVersionCompatibility,
26
+ markVersionCompatibility,
27
+ noneRunnableReason,
28
+ selectCompatibleVersion,
29
+ uncheckedVersionCompatibility,
30
+ } from "./version-compatibility.js";
31
+ export type {
32
+ IncompatibilityReason,
33
+ MarkedVersion,
34
+ ModuleCompatibility,
35
+ ModuleManifestReader,
36
+ VersionCompatibilityCheck,
37
+ VersionSelection,
38
+ } from "./version-compatibility.js";
@@ -0,0 +1,40 @@
1
+ import {
2
+ MANIFEST_CACHE_BASE_URL,
3
+ isOciRef,
4
+ manifestCacheUrl,
5
+ urlManifestCacheCoords,
6
+ withRefVersion,
7
+ } from "@telorun/analyzer";
8
+
9
+ /**
10
+ * Where one version of a module's `telo.yaml` can be read from the hub's static
11
+ * manifest cache, or `null` when this ref is not addressable there.
12
+ *
13
+ * This is the read path an upgrade check uses to ask a candidate version what
14
+ * runtime it requires: a plain CORS GET of a single small file, no transport
15
+ * protocol and no artifact payload, so it works identically from a browser and
16
+ * from an extension host. The `fetch` itself stays with the host — everything
17
+ * in this package leaves the network to its caller.
18
+ *
19
+ * A ref the cache cannot address (a registry ref, whose origin this side does
20
+ * not know; a URL with no version segment) returns `null`, which the caller
21
+ * reads as "not known" and never as "incompatible". Losing the check for those
22
+ * refs is the honest outcome: the manifest is still gated at load time.
23
+ */
24
+ export function moduleManifestCacheUrl(
25
+ baseRef: string,
26
+ version: string,
27
+ baseUrl: string = MANIFEST_CACHE_BASE_URL,
28
+ ): string | null {
29
+ if (isOciRef(baseRef)) {
30
+ let versioned: string;
31
+ try {
32
+ versioned = withRefVersion(baseRef, version);
33
+ } catch {
34
+ return null;
35
+ }
36
+ return manifestCacheUrl(versioned, baseUrl);
37
+ }
38
+ const coords = urlManifestCacheCoords(baseRef, version);
39
+ return coords ? manifestCacheUrl(coords, baseUrl) : null;
40
+ }
@@ -0,0 +1,196 @@
1
+ import {
2
+ TELO_SURFACE_VERSION,
3
+ compareModuleVersions,
4
+ isNewerModuleVersion,
5
+ manifestCompatibility,
6
+ parseModuleVersion,
7
+ type ModuleCompatibility,
8
+ } from "@telorun/analyzer";
9
+ import type { ModuleVersion } from "./build-import-upgrades.js";
10
+
11
+ export type { ModuleCompatibility };
12
+
13
+ /**
14
+ * Host capability: the `telo.yaml` text of one version of a module, or `null`
15
+ * when this host cannot address it (a transport it does not speak, a ref with
16
+ * no version segment). Rejecting is equally fine — both read as "not known",
17
+ * never as "incompatible".
18
+ *
19
+ * Narrow on purpose, and separate from {@link ModuleVersionLookup}: enumerating
20
+ * versions and reading one manifest are different routes with different costs,
21
+ * and a host caches or throttles them differently.
22
+ */
23
+ export type ModuleManifestReader = (
24
+ baseRef: string,
25
+ version: string,
26
+ ) => Promise<string | null | undefined>;
27
+
28
+ /** The question an upgrade asks of a candidate version. */
29
+ export type VersionCompatibilityCheck = (
30
+ baseRef: string,
31
+ version: string,
32
+ ) => Promise<ModuleCompatibility>;
33
+
34
+ /**
35
+ * A compatibility check backed by `read`, memoized per module and version for
36
+ * the lifetime of the returned function.
37
+ *
38
+ * The cache is not an optimization detail — an IDE re-derives its upgrade
39
+ * affordances on every keystroke, and a published version's declared
40
+ * requirement is immutable, so refetching it would be pure waste against an
41
+ * answer that cannot have changed. In-flight promises are shared, so the
42
+ * concurrent lookups one file's imports produce collapse to one read each.
43
+ *
44
+ * The verdict is always the analyzer's {@link manifestCompatibility}: an IDE
45
+ * reports NO host versions, because it is not the machine that will run the
46
+ * manifest. Only the telo surface is checked here; a host requirement still
47
+ * surfaces at the load gate when the manifest is actually run.
48
+ */
49
+ export function createVersionCompatibility(
50
+ read: ModuleManifestReader,
51
+ teloVersion: string = TELO_SURFACE_VERSION,
52
+ ): VersionCompatibilityCheck {
53
+ const cache = new Map<string, Promise<ModuleCompatibility>>();
54
+ return (baseRef, version) => {
55
+ const key = `${baseRef}@${version}`;
56
+ const cached = cache.get(key);
57
+ if (cached) return cached;
58
+ const pending = read(baseRef, version)
59
+ .then((text) => (text ? manifestCompatibility(text, teloVersion) : "unknown"))
60
+ .catch(() => "unknown" as const);
61
+ cache.set(key, pending);
62
+ // Only a DECIDED verdict is kept. What is immutable is a published version's
63
+ // declared requirement, not the network: caching `unknown` would let one
64
+ // offline moment disable the check for the rest of the session, leaving the
65
+ // UI reading as though every version had been checked and cleared. Dropped
66
+ // after it settles rather than before, so concurrent asks still share the
67
+ // one in-flight read.
68
+ void pending.then((verdict) => {
69
+ if (verdict === "unknown" && cache.get(key) === pending) cache.delete(key);
70
+ });
71
+ return pending;
72
+ };
73
+ }
74
+
75
+ /** A check that answers `unknown` for everything — the honest reading for a
76
+ * host with no way to fetch a candidate manifest. Never blocks an upgrade. */
77
+ export const uncheckedVersionCompatibility: VersionCompatibilityCheck = async () => "unknown";
78
+
79
+ /** Why a candidate was not offered. `unknown` never appears: a version that
80
+ * could not be read is offered, since an unreachable source must not silently
81
+ * freeze an author's imports. */
82
+ export type IncompatibilityReason = Exclude<ModuleCompatibility, "yes" | "unknown">;
83
+
84
+ export interface VersionSelection {
85
+ /** The newest version this runtime can host that is also newer than the
86
+ * current one, or `null` when there is none. */
87
+ best: ModuleVersion | null;
88
+ /** The newest candidate overall, when it is NOT `best` — what was held back.
89
+ * Reported rather than swallowed: without it an upgrade affordance says
90
+ * "up to date" while newer versions exist, which is a silent ceiling and a
91
+ * worse report than the load failure this whole check replaces. */
92
+ heldBack: { version: string; reason: IncompatibilityReason } | null;
93
+ }
94
+
95
+ /**
96
+ * The newest version whose declared requirement accepts this runtime, among
97
+ * those newer than `currentVersion`.
98
+ *
99
+ * Walks newest-first and stops at the first satisfied candidate, so the common
100
+ * case — the newest version is compatible — costs a single manifest read.
101
+ * Candidates older than the current version are never considered: an upgrade
102
+ * that walks backwards is a downgrade nobody asked for. Pass `null` for an
103
+ * import that does not exist yet, where every published version is a candidate
104
+ * and there is nothing to walk backwards from.
105
+ */
106
+ export async function selectCompatibleVersion(
107
+ baseRef: string,
108
+ versions: readonly ModuleVersion[],
109
+ currentVersion: string | null,
110
+ isCompatible: VersionCompatibilityCheck,
111
+ options: { includePrerelease?: boolean } = {},
112
+ ): Promise<VersionSelection> {
113
+ const candidates = upgradeCandidates(versions, currentVersion, options);
114
+ if (candidates.length === 0) return { best: null, heldBack: null };
115
+
116
+ const newest = candidates[0]!;
117
+ let firstReason: IncompatibilityReason | null = null;
118
+ for (const candidate of candidates) {
119
+ const verdict = await isCompatible(baseRef, candidate.version);
120
+ if (verdict === "too-new" || verdict === "unreadable") {
121
+ firstReason ??= verdict;
122
+ continue;
123
+ }
124
+ const held = candidate === newest ? null : { version: newest.version, reason: firstReason! };
125
+ return { best: candidate, heldBack: held };
126
+ }
127
+ return { best: null, heldBack: { version: newest.version, reason: firstReason! } };
128
+ }
129
+
130
+ /** One version as a picker renders it: what it is, and what this runtime makes
131
+ * of it. */
132
+ export interface MarkedVersion extends ModuleVersion {
133
+ compatibility: ModuleCompatibility;
134
+ }
135
+
136
+ /**
137
+ * Every version, each carrying its own verdict — what a deliberate pick needs.
138
+ *
139
+ * Unlike {@link selectCompatibleVersion} this cannot short-circuit: a picker
140
+ * marks entries it is not going to choose, so every entry has to be asked. That
141
+ * is why the two are separate operations rather than one with a flag — the
142
+ * automatic path must stay one read, and the picker is an explicit action on a
143
+ * single import whose reads are cached from then on.
144
+ */
145
+ export async function markVersionCompatibility(
146
+ baseRef: string,
147
+ versions: readonly ModuleVersion[],
148
+ isCompatible: VersionCompatibilityCheck,
149
+ ): Promise<MarkedVersion[]> {
150
+ return Promise.all(
151
+ versions.map(async (version) => ({
152
+ ...version,
153
+ compatibility: await isCompatible(baseRef, version.version),
154
+ })),
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Why nothing in a marked list can be offered, or `null` when something can.
160
+ *
161
+ * A picker has to state this outright: a list where every row is marked
162
+ * explains nothing on its own, and the reader is left thinking the tool is
163
+ * broken. A reason rather than a boolean because the two rejections have
164
+ * different remedies — see {@link describeRemedy}. `unknown` counts as
165
+ * offerable: a version this host could not check is not one it may refuse.
166
+ */
167
+ export function noneRunnableReason(
168
+ versions: readonly MarkedVersion[],
169
+ ): IncompatibilityReason | null {
170
+ if (versions.length === 0) return null;
171
+ if (versions.some((v) => v.compatibility === "yes" || v.compatibility === "unknown")) {
172
+ return null;
173
+ }
174
+ // The newest blocked version's reason, in the list's own (newest-first)
175
+ // order: it is the one the reader is asking about.
176
+ return versions[0]!.compatibility as IncompatibilityReason;
177
+ }
178
+
179
+ /** Candidates strictly newer than `currentVersion`, newest first.
180
+ *
181
+ * Ordering and prerelease exclusion come from the analyzer's shared rule, so
182
+ * "is this import behind" and "what would it move to" cannot disagree. */
183
+ function upgradeCandidates(
184
+ versions: readonly ModuleVersion[],
185
+ currentVersion: string | null,
186
+ options: { includePrerelease?: boolean },
187
+ ): ModuleVersion[] {
188
+ return versions
189
+ .filter((v) => {
190
+ const parsed = parseModuleVersion(v.version);
191
+ if (parsed === null) return false;
192
+ if (parsed.pre !== null && !options.includePrerelease) return false;
193
+ return currentVersion === null || isNewerModuleVersion(v.version, currentVersion);
194
+ })
195
+ .sort((a, b) => compareModuleVersions(b.version, a.version) ?? 0);
196
+ }
@@ -5,60 +5,114 @@ import {
5
5
  type AnalysisRegistry,
6
6
  type AstDocument,
7
7
  type AstNode,
8
+ type AstScalar,
9
+ type CelScope,
10
+ type ManifestAnalysis,
8
11
  } from "@telorun/analyzer";
9
12
  import type { SemanticToken } from "../types.js";
13
+ import { celSegmentTokens } from "../cel/tokens.js";
14
+ import { docIdentity } from "../doc-identity.js";
10
15
  import { scalarString } from "../completions/resolve-node.js";
11
16
  import { CAPABILITY_VALUES } from "../completions/valid-capabilities.js";
12
17
 
13
18
  const CAPABILITIES = new Set<string>(CAPABILITY_VALUES);
14
19
 
20
+ /** Append one key segment to a concrete path (`routes[0]` + `handler`). */
21
+ function joinKey(concrete: string, key: string): string {
22
+ return concrete ? `${concrete}.${key}` : key;
23
+ }
24
+
15
25
  /** Registry-aware semantic tokens: a `kind:` value that resolves to a known
16
26
  * definition is a `type`; a `capability:` value is an `interface`; a `!ref`
17
- * target is a `variable`. Everything else (structure, CEL, tags) is left to the
18
- * TextMate grammar. Ref targets are colored here rather than in the grammar
19
- * because a `!ref` after a `key:` is tokenized by the bundled YAML grammar
20
- * before a Telo pattern can claim it the AST sees it unambiguously. An
21
- * unresolved kind gets no token, so a typo stays uncolored — a quiet signal
22
- * that pairs with the analyzer's `UNDEFINED_KIND` diagnostic. */
27
+ * target is a `variable`. Ref targets are colored here rather than in the
28
+ * grammar because a `!ref` after a `key:` is tokenized by the bundled YAML
29
+ * grammar before a Telo pattern can claim it the AST sees it unambiguously.
30
+ * An unresolved kind gets no token, so a typo stays uncolored a quiet signal
31
+ * that pairs with the analyzer's `UNDEFINED_KIND` diagnostic.
32
+ *
33
+ * The inside of a `!cel` / `${{ }}` body is colored here too, and for the same
34
+ * reason one level down: a grammar can only know the roots someone hardcoded
35
+ * into it, while `scopeQuery` knows what is in scope at this exact site. With
36
+ * no query the names are colored syntactically instead — a CEL body must never
37
+ * read as a plain string, which is what the stock YAML grammar makes of it. */
23
38
  export function buildSemanticTokens(
24
39
  text: string,
25
40
  registry: AnalysisRegistry | undefined,
26
41
  docs?: AstDocument[],
42
+ analysis?: ManifestAnalysis,
27
43
  ): SemanticToken[] {
28
44
  const astDocs = docs ?? parseToAst(text);
29
45
  const lineOffsets = buildLineOffsets(text);
30
46
 
31
47
  const tokens: SemanticToken[] = [];
32
- const emit = (node: AstNode | undefined, type: SemanticToken["type"]): void => {
33
- if (!node) return;
34
- const start = offsetToPosition(node.range[0], lineOffsets);
35
- const end = offsetToPosition(node.range[1], lineOffsets);
36
- // Kind / capability values never span lines; a clamped single-line token.
48
+ const emitRange = (range: [number, number], type: SemanticToken["type"]): void => {
49
+ const start = offsetToPosition(range[0], lineOffsets);
50
+ const end = offsetToPosition(range[1], lineOffsets);
51
+ // Kind / capability values and CEL identifiers never span lines; a clamped
52
+ // single-line token.
37
53
  if (start.line !== end.line) return;
38
- tokens.push({ line: start.line, character: start.character, length: end.character - start.character, type });
54
+ tokens.push({
55
+ line: start.line,
56
+ character: start.character,
57
+ length: end.character - start.character,
58
+ type,
59
+ });
60
+ };
61
+ const emit = (node: AstNode | undefined, type: SemanticToken["type"]): void => {
62
+ if (node) emitRange(node.range, type);
39
63
  };
40
64
 
41
- const walk = (node: AstNode): void => {
42
- if (node.kind === "map") {
43
- for (const pair of node.entries) {
44
- const key = scalarString(pair.key);
45
- const value = scalarString(pair.value);
46
- if (key === "kind" && value && registry?.resolveDefinition(value)) {
47
- emit(pair.value, "type");
48
- } else if (key === "capability" && value && CAPABILITIES.has(value)) {
49
- emit(pair.value, "interface");
65
+ for (const doc of astDocs) {
66
+ if (!doc.root) continue;
67
+
68
+ // The scope is resolved per SITE. `scopeAt` caches per (resource, path) for
69
+ // the analysis's lifetime, which is what keeps a whole-file colourizer off
70
+ // the per-keystroke cost of rebuilding a context-matched environment.
71
+ const identity = docIdentity(doc);
72
+ const scopeQuery = analysis?.celScope;
73
+ const resource = scopeQuery?.resourceFor(identity.kind, identity.name);
74
+ const scopeAt = (path: string): CelScope | undefined =>
75
+ scopeQuery && resource ? scopeQuery.scopeAt(resource, path) : undefined;
76
+
77
+ const celTokens = (node: AstScalar, path: string): void => {
78
+ const segments = node.celSegments();
79
+ if (segments.length === 0) return;
80
+ const scope = scopeAt(path);
81
+ for (const segment of segments) {
82
+ for (const span of celSegmentTokens(text, segment, scope)) emitRange(span.range, span.type);
83
+ }
84
+ };
85
+
86
+ const walk = (node: AstNode, concrete: string): void => {
87
+ if (node.kind === "map") {
88
+ for (const pair of node.entries) {
89
+ const key = scalarString(pair.key);
90
+ const value = scalarString(pair.value);
91
+ if (key === "kind" && value && registry?.resolveDefinition(value)) {
92
+ emit(pair.value, "type");
93
+ } else if (key === "capability" && value && CAPABILITIES.has(value)) {
94
+ emit(pair.value, "interface");
95
+ }
96
+ if (pair.value) walk(pair.value, key != null ? joinKey(concrete, key) : concrete);
50
97
  }
51
- if (pair.value) walk(pair.value);
98
+ return;
52
99
  }
53
- } else if (node.kind === "seq") {
54
- for (const item of node.items) walk(item);
55
- } else if (node.kind === "scalar" && node.tag === "!ref") {
56
- emit(node, "variable");
57
- }
58
- };
100
+ if (node.kind === "seq") {
101
+ // Indices are kept: a CEL site's scope is addressed per item, so an
102
+ // index-erased path resolves the wrong context or none.
103
+ node.items.forEach((item, index) => walk(item, `${concrete}[${index}]`));
104
+ return;
105
+ }
106
+ if (node.kind === "scalar") {
107
+ if (node.tag === "!ref") {
108
+ emit(node, "variable");
109
+ return;
110
+ }
111
+ celTokens(node, concrete);
112
+ }
113
+ };
59
114
 
60
- for (const doc of astDocs) {
61
- if (doc.root) walk(doc.root);
115
+ walk(doc.root, "");
62
116
  }
63
117
  return tokens;
64
118
  }