@telorun/ide-support 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { type AstDocument, type Range } from "@telorun/analyzer";
2
+ import { type IncompatibilityReason, type VersionCompatibilityCheck } from "./version-compatibility.js";
2
3
  /** One version of a module, as the hub reports it.
3
4
  *
4
5
  * `integrity` is the import pin for exactly this version (`sha256-<base64url>`
@@ -23,6 +24,22 @@ export interface ModuleVersion {
23
24
  * `GET /module/versions` and passes the body through
24
25
  * {@link parseModuleVersions}. */
25
26
  export type ModuleVersionLookup = (baseRef: string) => Promise<ModuleVersion[]>;
27
+ /** Everything an upgrade needs from its host: what versions exist, and whether
28
+ * this runtime can host one.
29
+ *
30
+ * `isCompatible` is required rather than optional so no host can quietly skip
31
+ * the check and walk an author onto a version their telo cannot load — the gap
32
+ * this package existed with. A host that genuinely cannot fetch candidate
33
+ * manifests passes {@link uncheckedVersionCompatibility}, which says so
34
+ * instead of pretending. */
35
+ export interface ImportUpgradeEnvironment {
36
+ listVersions: ModuleVersionLookup;
37
+ isCompatible: VersionCompatibilityCheck;
38
+ /** Offer prereleases as automatic upgrade targets. Off by default, matching
39
+ * `telo upgrade`: an upgrade nobody asked for must not walk a caller onto an
40
+ * `-rc` build. */
41
+ includePrerelease?: boolean;
42
+ }
26
43
  /** A source edit a host applies verbatim to upgrade an import. Ranges never
27
44
  * overlap, within an upgrade or across a batch, so a host may apply the whole
28
45
  * set in one pass without ordering them. */
@@ -50,6 +67,14 @@ export interface ImportUpgrade {
50
67
  * means no pin was available for the target version — a host should say so
51
68
  * when `wasPinned`, since the rewrite silently drops a hash the author had. */
52
69
  repinned: boolean;
70
+ /** A newer version that exists but this runtime cannot host, when
71
+ * `latestVersion` is not the newest published. Surfacing it is not optional:
72
+ * an upgrade that quietly stops short of the newest version, with no reason
73
+ * given, reads as a bug in the tooling. */
74
+ heldBack?: {
75
+ version: string;
76
+ reason: IncompatibilityReason;
77
+ };
53
78
  /** Span of the alias key — where a per-entry affordance anchors. */
54
79
  keyRange: Range;
55
80
  /** Apply all of these to upgrade this one import. */
@@ -79,8 +104,18 @@ export interface ImportUpgradeSkip {
79
104
  latestVersion: string;
80
105
  /** Span of the alias key — where a per-entry affordance anchors. */
81
106
  keyRange: Range;
82
- /** Author-facing sentence: what was not done, and what to run instead. */
83
- reason: string;
107
+ /** Which decision this was, for a host that styles or filters them.
108
+ * `incompatible` — every newer version declares a telo this runtime is not;
109
+ * `stale-inline-pin` — the rewrite could not carry or drop the author's pin. */
110
+ code: "incompatible" | "stale-inline-pin";
111
+ /** Which rejection produced an `incompatible` skip, so a host can phrase its
112
+ * own affordance without re-deriving one. Carried BESIDE `code` because the
113
+ * code says what was not done while this says why, and the remedies differ:
114
+ * updating telo cannot fix a requirement the module failed to state. Absent
115
+ * for `stale-inline-pin`, which is not a compatibility decision at all. */
116
+ reason?: IncompatibilityReason;
117
+ /** Author-facing sentence: what was not done, and what to do instead. */
118
+ message: string;
84
119
  }
85
120
  export interface ImportUpgradeSet {
86
121
  /** Span of the `imports:` key — where a summary affordance anchors. */
@@ -102,6 +137,13 @@ export interface ImportUpgradeSet {
102
137
  * re-point it — plus every entry already at the newest version that carries no
103
138
  * integrity pin, and the edits that pin it.
104
139
  *
140
+ * The target is the newest version this runtime can HOST, which is not always
141
+ * the newest one published: each candidate's declared `requires.telo` is checked
142
+ * newest-first, and a newer version that was held back is reported on the
143
+ * upgrade rather than dropped. An import whose every newer version needs a newer
144
+ * telo produces a `skipped` entry instead, since the alternative — offering it
145
+ * anyway — is a manifest that fails at the load gate.
146
+ *
105
147
  * Skips what carries no upgradeable version: local path imports, bare URLs,
106
148
  * untagged refs, and pins that are not SemVer (an OCI digest, a moving tag like
107
149
  * `latest`) — `parseVersionedRef` and `isNewerModuleVersion` both decline to
@@ -111,5 +153,17 @@ export interface ImportUpgradeSet {
111
153
  * API. Returns `undefined` when the file declares no module document or the
112
154
  * module declares no `imports:`.
113
155
  */
114
- export declare function buildImportUpgrades(text: string, listVersions: ModuleVersionLookup, docs?: AstDocument[]): Promise<ImportUpgradeSet | undefined>;
156
+ export declare function buildImportUpgrades(text: string, env: ImportUpgradeEnvironment, docs?: AstDocument[]): Promise<ImportUpgradeSet | undefined>;
157
+ /** How a held-back version reads in a sentence, with the version itself as the
158
+ * implied subject. Names the cause the check actually established.
159
+ *
160
+ * The parameter is REQUIRED, so a caller cannot reach a sentence for a cause
161
+ * nothing established — defaulting an absent reason to "requires a newer telo"
162
+ * is precisely the invented verdict the four-way split exists to prevent. */
163
+ export declare function describeReason(reason: IncompatibilityReason): string;
164
+ /** What the author can do about it. Split from {@link describeReason} because
165
+ * the two rejections have DIFFERENT remedies: an unreadable requirement is not
166
+ * a version skew, so telling the author to update telo would send them after a
167
+ * fix that cannot work — only the module's own author can repair it. */
168
+ export declare function describeRemedy(reason: IncompatibilityReason): string;
115
169
  //# sourceMappingURL=build-import-upgrades.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"build-import-upgrades.d.ts","sourceRoot":"","sources":["../../src/import-upgrades/build-import-upgrades.ts"],"names":[],"mappings":"AAAA,OAAO,EAYL,KAAK,WAAW,EAChB,KAAK,KAAK,EACX,MAAM,mBAAmB,CAAC;AAG3B;;;;;oEAKoE;AACpE,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;mCAYmC;AACnC,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,EAAE,CAAC,CAAC;AAEhF;;6CAE6C;AAC7C,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qDAAqD;AACrD,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;6EAKyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,SAAS,EAAE,OAAO,CAAC;IACnB;;oFAEgF;IAChF,QAAQ,EAAE,OAAO,CAAC;IAClB,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;IAChB,qDAAqD;IACrD,KAAK,EAAE,iBAAiB,EAAE,CAAC;CAC5B;AAED;;;kFAGkF;AAClF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;IAChB,KAAK,EAAE,iBAAiB,EAAE,CAAC;CAC5B;AAED;;;yDAGyD;AACzD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;IAChB,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,uEAAuE;IACvE,eAAe,EAAE,KAAK,CAAC;IACvB,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB,OAAO,EAAE,iBAAiB,EAAE,CAAC;IAC7B;;wDAEoD;IACpD,QAAQ,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACvD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,mBAAmB,EACjC,IAAI,CAAC,EAAE,WAAW,EAAE,GACnB,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAmEvC"}
1
+ {"version":3,"file":"build-import-upgrades.d.ts","sourceRoot":"","sources":["../../src/import-upgrades/build-import-upgrades.ts"],"names":[],"mappings":"AAAA,OAAO,EAaL,KAAK,WAAW,EAChB,KAAK,KAAK,EACX,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAEL,KAAK,qBAAqB,EAC1B,KAAK,yBAAyB,EAC/B,MAAM,4BAA4B,CAAC;AAEpC;;;;;oEAKoE;AACpE,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;mCAYmC;AACnC,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,EAAE,CAAC,CAAC;AAEhF;;;;;;;6BAO6B;AAC7B,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,mBAAmB,CAAC;IAClC,YAAY,EAAE,yBAAyB,CAAC;IACxC;;uBAEmB;IACnB,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;6CAE6C;AAC7C,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,KAAK,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qDAAqD;AACrD,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;6EAKyE;IACzE,SAAS,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,SAAS,EAAE,OAAO,CAAC;IACnB;;oFAEgF;IAChF,QAAQ,EAAE,OAAO,CAAC;IAClB;;;gDAG4C;IAC5C,QAAQ,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,qBAAqB,CAAA;KAAE,CAAC;IAC9D,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;IAChB,qDAAqD;IACrD,KAAK,EAAE,iBAAiB,EAAE,CAAC;CAC5B;AAED;;;kFAGkF;AAClF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;IAChB,KAAK,EAAE,iBAAiB,EAAE,CAAC;CAC5B;AAED;;;yDAGyD;AACzD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;IAChB;;qFAEiF;IACjF,IAAI,EAAE,cAAc,GAAG,kBAAkB,CAAC;IAC1C;;;;gFAI4E;IAC5E,MAAM,CAAC,EAAE,qBAAqB,CAAC;IAC/B,yEAAyE;IACzE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,uEAAuE;IACvE,eAAe,EAAE,KAAK,CAAC;IACvB,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB,OAAO,EAAE,iBAAiB,EAAE,CAAC;IAC7B;;wDAEoD;IACpD,QAAQ,EAAE,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACvD;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,wBAAwB,EAC7B,IAAI,CAAC,EAAE,WAAW,EAAE,GACnB,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAwGvC;AAED;;;;;8EAK8E;AAC9E,wBAAgB,cAAc,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAIpE;AAED;;;yEAGyE;AACzE,wBAAgB,cAAc,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAIpE"}
@@ -1,11 +1,19 @@
1
- import { buildLineOffsets, foldIntegrity, isCanonicalIntegrity, isLocalPathSource, isNewerModuleVersion, isSameModuleVersion, newestModuleVersion, parseModuleVersion, parseToAst, parseVersionedRef, withRefVersion, } from "@telorun/analyzer";
1
+ import { TELO_SURFACE_VERSION, buildLineOffsets, foldIntegrity, isCanonicalIntegrity, isLocalPathSource, isNewerModuleVersion, isSameModuleVersion, newestModuleVersion, parseModuleVersion, parseToAst, parseVersionedRef, withRefVersion, } from "@telorun/analyzer";
2
2
  import { findImportEntries } from "./find-import-entries.js";
3
+ import { selectCompatibleVersion, } from "./version-compatibility.js";
3
4
  /**
4
5
  * Find every `imports:` entry of a module document that names a version older
5
6
  * than the newest one `listVersions` reports, and produce the source edits that
6
7
  * re-point it — plus every entry already at the newest version that carries no
7
8
  * integrity pin, and the edits that pin it.
8
9
  *
10
+ * The target is the newest version this runtime can HOST, which is not always
11
+ * the newest one published: each candidate's declared `requires.telo` is checked
12
+ * newest-first, and a newer version that was held back is reported on the
13
+ * upgrade rather than dropped. An import whose every newer version needs a newer
14
+ * telo produces a `skipped` entry instead, since the alternative — offering it
15
+ * anyway — is a manifest that fails at the load gate.
16
+ *
9
17
  * Skips what carries no upgradeable version: local path imports, bare URLs,
10
18
  * untagged refs, and pins that are not SemVer (an OCI digest, a moving tag like
11
19
  * `latest`) — `parseVersionedRef` and `isNewerModuleVersion` both decline to
@@ -15,7 +23,7 @@ import { findImportEntries } from "./find-import-entries.js";
15
23
  * API. Returns `undefined` when the file declares no module document or the
16
24
  * module declares no `imports:`.
17
25
  */
18
- export async function buildImportUpgrades(text, listVersions, docs) {
26
+ export async function buildImportUpgrades(text, env, docs) {
19
27
  const lineOffsets = buildLineOffsets(text);
20
28
  const block = findImportEntries(text, docs ?? parseToAst(text), lineOffsets);
21
29
  if (!block)
@@ -27,7 +35,7 @@ export async function buildImportUpgrades(text, listVersions, docs) {
27
35
  return ref ? [{ entry, ref }] : [];
28
36
  });
29
37
  const failures = [];
30
- const known = await resolveVersions([...new Set(candidates.map((c) => c.ref.baseRef))], listVersions, failures);
38
+ const known = await resolveVersions([...new Set(candidates.map((c) => c.ref.baseRef))], env.listVersions, failures);
31
39
  const upgrades = [];
32
40
  const pins = [];
33
41
  const skipped = [];
@@ -35,7 +43,9 @@ export async function buildImportUpgrades(text, listVersions, docs) {
35
43
  const versions = known.get(ref.baseRef);
36
44
  if (!versions)
37
45
  continue;
38
- const newest = newestModuleVersion(versions.map((v) => v.version));
46
+ const newest = newestModuleVersion(versions.map((v) => v.version), {
47
+ includePrerelease: env.includePrerelease,
48
+ });
39
49
  if (!newest)
40
50
  continue;
41
51
  if (!isNewerModuleVersion(newest, ref.version)) {
@@ -44,7 +54,32 @@ export async function buildImportUpgrades(text, listVersions, docs) {
44
54
  pins.push(pin);
45
55
  continue;
46
56
  }
47
- const integrity = integrityFor(versions, newest);
57
+ // The target is the newest version this runtime can actually host, not
58
+ // simply the newest one published. An upgrade the author's telo cannot load
59
+ // is not an upgrade — it is a load failure they were walked into.
60
+ const { best, heldBack } = await selectCompatibleVersion(ref.baseRef, versions, ref.version, env.isCompatible, { includePrerelease: env.includePrerelease });
61
+ // `heldBack` is non-null whenever a candidate existed, which `newest` being
62
+ // newer than the current version already established. Narrowed rather than
63
+ // defaulted: a reason nothing established is exactly what the four-verdict
64
+ // split exists to keep out of a message.
65
+ if (!best) {
66
+ if (!heldBack)
67
+ continue;
68
+ skipped.push({
69
+ alias: entry.alias,
70
+ currentVersion: ref.version,
71
+ latestVersion: newest,
72
+ keyRange: entry.keyRange,
73
+ code: "incompatible",
74
+ reason: heldBack.reason,
75
+ message: `'${entry.alias}' has newer versions (up to ${newest}), but none runs on telo ` +
76
+ `${TELO_SURFACE_VERSION} — ${describeReason(heldBack.reason)}. ` +
77
+ describeRemedy(heldBack.reason),
78
+ });
79
+ continue;
80
+ }
81
+ const target = best.version;
82
+ const integrity = integrityFor(versions, target);
48
83
  // A stale pin that can be neither replaced nor removed is the one case left
49
84
  // that has to be declined: re-pointing the source while leaving a hash for
50
85
  // the version it replaced turns the next install into a tamper error.
@@ -52,29 +87,51 @@ export async function buildImportUpgrades(text, listVersions, docs) {
52
87
  skipped.push({
53
88
  alias: entry.alias,
54
89
  currentVersion: ref.version,
55
- latestVersion: newest,
90
+ latestVersion: target,
56
91
  keyRange: entry.keyRange,
57
- reason: `'${entry.alias}' carries an inline 'integrity:' that shares a line with other ` +
58
- `fields, and no pin is published for ${newest}, so the stale one cannot be ` +
92
+ code: "stale-inline-pin",
93
+ message: `'${entry.alias}' carries an inline 'integrity:' that shares a line with other ` +
94
+ `fields, and no pin is published for ${target}, so the stale one cannot be ` +
59
95
  `removed by a line edit. Run \`telo upgrade\`.`,
60
96
  });
61
97
  continue;
62
98
  }
63
- const newSource = withRefVersion(entry.source, newest);
99
+ const newSource = withRefVersion(entry.source, target);
64
100
  upgrades.push({
65
101
  alias: entry.alias,
66
102
  source: entry.source,
67
103
  currentVersion: ref.version,
68
- latestVersion: newest,
104
+ latestVersion: target,
69
105
  newSource: foldIntegrity(newSource, integrity),
70
106
  wasPinned: ref.integrity != null,
71
107
  repinned: integrity != null,
72
108
  keyRange: entry.keyRange,
109
+ ...(heldBack ? { heldBack } : {}),
73
110
  edits: buildEdits(entry, newSource, integrity),
74
111
  });
75
112
  }
76
113
  return { importsKeyRange: block.keyRange, upgrades, pins, skipped, failures };
77
114
  }
115
+ /** How a held-back version reads in a sentence, with the version itself as the
116
+ * implied subject. Names the cause the check actually established.
117
+ *
118
+ * The parameter is REQUIRED, so a caller cannot reach a sentence for a cause
119
+ * nothing established — defaulting an absent reason to "requires a newer telo"
120
+ * is precisely the invented verdict the four-way split exists to prevent. */
121
+ export function describeReason(reason) {
122
+ return reason === "unreadable"
123
+ ? "its declared requirement cannot be read"
124
+ : "it requires a newer telo";
125
+ }
126
+ /** What the author can do about it. Split from {@link describeReason} because
127
+ * the two rejections have DIFFERENT remedies: an unreadable requirement is not
128
+ * a version skew, so telling the author to update telo would send them after a
129
+ * fix that cannot work — only the module's own author can repair it. */
130
+ export function describeRemedy(reason) {
131
+ return reason === "unreadable"
132
+ ? "Only the module's author can fix that."
133
+ : "Update telo to upgrade it.";
134
+ }
78
135
  /** The edits that re-point an entry at `newSource` and settle its pin.
79
136
  *
80
137
  * Where the pin is written follows the shape the author chose: an entry with an
@@ -1,6 +1,9 @@
1
- export { buildImportUpgrades } from "./build-import-upgrades.js";
2
- export type { ImportPin, ImportUpgrade, ImportUpgradeEdit, ImportUpgradeSet, ImportUpgradeSkip, ModuleVersion, ModuleVersionLookup, } from "./build-import-upgrades.js";
1
+ export { buildImportUpgrades, describeReason, describeRemedy, } from "./build-import-upgrades.js";
2
+ export type { ImportPin, ImportUpgrade, ImportUpgradeEdit, ImportUpgradeEnvironment, ImportUpgradeSet, ImportUpgradeSkip, ModuleVersion, ModuleVersionLookup, } from "./build-import-upgrades.js";
3
+ export { moduleManifestCacheUrl } from "./manifest-cache-url.js";
3
4
  export { parseModuleVersions } from "./parse-module-versions.js";
4
5
  export { findImportEntries } from "./find-import-entries.js";
5
6
  export type { ImportEntry, ImportEntryIntegrity, ImportsBlock, } from "./find-import-entries.js";
7
+ export { createVersionCompatibility, markVersionCompatibility, noneRunnableReason, selectCompatibleVersion, uncheckedVersionCompatibility, } from "./version-compatibility.js";
8
+ export type { IncompatibilityReason, MarkedVersion, ModuleCompatibility, ModuleManifestReader, VersionCompatibilityCheck, VersionSelection, } from "./version-compatibility.js";
6
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/import-upgrades/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,YAAY,EACV,SAAS,EACT,aAAa,EACb,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,aAAa,EACb,mBAAmB,GACpB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EACV,WAAW,EACX,oBAAoB,EACpB,YAAY,GACb,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/import-upgrades/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,cAAc,GACf,MAAM,4BAA4B,CAAC;AACpC,YAAY,EACV,SAAS,EACT,aAAa,EACb,iBAAiB,EACjB,wBAAwB,EACxB,gBAAgB,EAChB,iBAAiB,EACjB,aAAa,EACb,mBAAmB,GACpB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EACV,WAAW,EACX,oBAAoB,EACpB,YAAY,GACb,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,0BAA0B,EAC1B,wBAAwB,EACxB,kBAAkB,EAClB,uBAAuB,EACvB,6BAA6B,GAC9B,MAAM,4BAA4B,CAAC;AACpC,YAAY,EACV,qBAAqB,EACrB,aAAa,EACb,mBAAmB,EACnB,oBAAoB,EACpB,yBAAyB,EACzB,gBAAgB,GACjB,MAAM,4BAA4B,CAAC"}
@@ -1,3 +1,5 @@
1
- export { buildImportUpgrades } from "./build-import-upgrades.js";
1
+ export { buildImportUpgrades, describeReason, describeRemedy, } from "./build-import-upgrades.js";
2
+ export { moduleManifestCacheUrl } from "./manifest-cache-url.js";
2
3
  export { parseModuleVersions } from "./parse-module-versions.js";
3
4
  export { findImportEntries } from "./find-import-entries.js";
5
+ export { createVersionCompatibility, markVersionCompatibility, noneRunnableReason, selectCompatibleVersion, uncheckedVersionCompatibility, } from "./version-compatibility.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Where one version of a module's `telo.yaml` can be read from the hub's static
3
+ * manifest cache, or `null` when this ref is not addressable there.
4
+ *
5
+ * This is the read path an upgrade check uses to ask a candidate version what
6
+ * runtime it requires: a plain CORS GET of a single small file, no transport
7
+ * protocol and no artifact payload, so it works identically from a browser and
8
+ * from an extension host. The `fetch` itself stays with the host — everything
9
+ * in this package leaves the network to its caller.
10
+ *
11
+ * A ref the cache cannot address (a registry ref, whose origin this side does
12
+ * not know; a URL with no version segment) returns `null`, which the caller
13
+ * reads as "not known" and never as "incompatible". Losing the check for those
14
+ * refs is the honest outcome: the manifest is still gated at load time.
15
+ */
16
+ export declare function moduleManifestCacheUrl(baseRef: string, version: string, baseUrl?: string): string | null;
17
+ //# sourceMappingURL=manifest-cache-url.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest-cache-url.d.ts","sourceRoot":"","sources":["../../src/import-upgrades/manifest-cache-url.ts"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,MAAgC,GACxC,MAAM,GAAG,IAAI,CAYf"}
@@ -0,0 +1,30 @@
1
+ import { MANIFEST_CACHE_BASE_URL, isOciRef, manifestCacheUrl, urlManifestCacheCoords, withRefVersion, } from "@telorun/analyzer";
2
+ /**
3
+ * Where one version of a module's `telo.yaml` can be read from the hub's static
4
+ * manifest cache, or `null` when this ref is not addressable there.
5
+ *
6
+ * This is the read path an upgrade check uses to ask a candidate version what
7
+ * runtime it requires: a plain CORS GET of a single small file, no transport
8
+ * protocol and no artifact payload, so it works identically from a browser and
9
+ * from an extension host. The `fetch` itself stays with the host — everything
10
+ * in this package leaves the network to its caller.
11
+ *
12
+ * A ref the cache cannot address (a registry ref, whose origin this side does
13
+ * not know; a URL with no version segment) returns `null`, which the caller
14
+ * reads as "not known" and never as "incompatible". Losing the check for those
15
+ * refs is the honest outcome: the manifest is still gated at load time.
16
+ */
17
+ export function moduleManifestCacheUrl(baseRef, version, baseUrl = MANIFEST_CACHE_BASE_URL) {
18
+ if (isOciRef(baseRef)) {
19
+ let versioned;
20
+ try {
21
+ versioned = withRefVersion(baseRef, version);
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ return manifestCacheUrl(versioned, baseUrl);
27
+ }
28
+ const coords = urlManifestCacheCoords(baseRef, version);
29
+ return coords ? manifestCacheUrl(coords, baseUrl) : null;
30
+ }
@@ -0,0 +1,92 @@
1
+ import { type ModuleCompatibility } from "@telorun/analyzer";
2
+ import type { ModuleVersion } from "./build-import-upgrades.js";
3
+ export type { ModuleCompatibility };
4
+ /**
5
+ * Host capability: the `telo.yaml` text of one version of a module, or `null`
6
+ * when this host cannot address it (a transport it does not speak, a ref with
7
+ * no version segment). Rejecting is equally fine — both read as "not known",
8
+ * never as "incompatible".
9
+ *
10
+ * Narrow on purpose, and separate from {@link ModuleVersionLookup}: enumerating
11
+ * versions and reading one manifest are different routes with different costs,
12
+ * and a host caches or throttles them differently.
13
+ */
14
+ export type ModuleManifestReader = (baseRef: string, version: string) => Promise<string | null | undefined>;
15
+ /** The question an upgrade asks of a candidate version. */
16
+ export type VersionCompatibilityCheck = (baseRef: string, version: string) => Promise<ModuleCompatibility>;
17
+ /**
18
+ * A compatibility check backed by `read`, memoized per module and version for
19
+ * the lifetime of the returned function.
20
+ *
21
+ * The cache is not an optimization detail — an IDE re-derives its upgrade
22
+ * affordances on every keystroke, and a published version's declared
23
+ * requirement is immutable, so refetching it would be pure waste against an
24
+ * answer that cannot have changed. In-flight promises are shared, so the
25
+ * concurrent lookups one file's imports produce collapse to one read each.
26
+ *
27
+ * The verdict is always the analyzer's {@link manifestCompatibility}: an IDE
28
+ * reports NO host versions, because it is not the machine that will run the
29
+ * manifest. Only the telo surface is checked here; a host requirement still
30
+ * surfaces at the load gate when the manifest is actually run.
31
+ */
32
+ export declare function createVersionCompatibility(read: ModuleManifestReader, teloVersion?: string): VersionCompatibilityCheck;
33
+ /** A check that answers `unknown` for everything — the honest reading for a
34
+ * host with no way to fetch a candidate manifest. Never blocks an upgrade. */
35
+ export declare const uncheckedVersionCompatibility: VersionCompatibilityCheck;
36
+ /** Why a candidate was not offered. `unknown` never appears: a version that
37
+ * could not be read is offered, since an unreachable source must not silently
38
+ * freeze an author's imports. */
39
+ export type IncompatibilityReason = Exclude<ModuleCompatibility, "yes" | "unknown">;
40
+ export interface VersionSelection {
41
+ /** The newest version this runtime can host that is also newer than the
42
+ * current one, or `null` when there is none. */
43
+ best: ModuleVersion | null;
44
+ /** The newest candidate overall, when it is NOT `best` — what was held back.
45
+ * Reported rather than swallowed: without it an upgrade affordance says
46
+ * "up to date" while newer versions exist, which is a silent ceiling and a
47
+ * worse report than the load failure this whole check replaces. */
48
+ heldBack: {
49
+ version: string;
50
+ reason: IncompatibilityReason;
51
+ } | null;
52
+ }
53
+ /**
54
+ * The newest version whose declared requirement accepts this runtime, among
55
+ * those newer than `currentVersion`.
56
+ *
57
+ * Walks newest-first and stops at the first satisfied candidate, so the common
58
+ * case — the newest version is compatible — costs a single manifest read.
59
+ * Candidates older than the current version are never considered: an upgrade
60
+ * that walks backwards is a downgrade nobody asked for. Pass `null` for an
61
+ * import that does not exist yet, where every published version is a candidate
62
+ * and there is nothing to walk backwards from.
63
+ */
64
+ export declare function selectCompatibleVersion(baseRef: string, versions: readonly ModuleVersion[], currentVersion: string | null, isCompatible: VersionCompatibilityCheck, options?: {
65
+ includePrerelease?: boolean;
66
+ }): Promise<VersionSelection>;
67
+ /** One version as a picker renders it: what it is, and what this runtime makes
68
+ * of it. */
69
+ export interface MarkedVersion extends ModuleVersion {
70
+ compatibility: ModuleCompatibility;
71
+ }
72
+ /**
73
+ * Every version, each carrying its own verdict — what a deliberate pick needs.
74
+ *
75
+ * Unlike {@link selectCompatibleVersion} this cannot short-circuit: a picker
76
+ * marks entries it is not going to choose, so every entry has to be asked. That
77
+ * is why the two are separate operations rather than one with a flag — the
78
+ * automatic path must stay one read, and the picker is an explicit action on a
79
+ * single import whose reads are cached from then on.
80
+ */
81
+ export declare function markVersionCompatibility(baseRef: string, versions: readonly ModuleVersion[], isCompatible: VersionCompatibilityCheck): Promise<MarkedVersion[]>;
82
+ /**
83
+ * Why nothing in a marked list can be offered, or `null` when something can.
84
+ *
85
+ * A picker has to state this outright: a list where every row is marked
86
+ * explains nothing on its own, and the reader is left thinking the tool is
87
+ * broken. A reason rather than a boolean because the two rejections have
88
+ * different remedies — see {@link describeRemedy}. `unknown` counts as
89
+ * offerable: a version this host could not check is not one it may refuse.
90
+ */
91
+ export declare function noneRunnableReason(versions: readonly MarkedVersion[]): IncompatibilityReason | null;
92
+ //# sourceMappingURL=version-compatibility.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version-compatibility.d.ts","sourceRoot":"","sources":["../../src/import-upgrades/version-compatibility.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,mBAAmB,EACzB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAEhE,YAAY,EAAE,mBAAmB,EAAE,CAAC;AAEpC;;;;;;;;;GASG;AACH,MAAM,MAAM,oBAAoB,GAAG,CACjC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,KACZ,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;AAExC,2DAA2D;AAC3D,MAAM,MAAM,yBAAyB,GAAG,CACtC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,KACZ,OAAO,CAAC,mBAAmB,CAAC,CAAC;AAElC;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,oBAAoB,EAC1B,WAAW,GAAE,MAA6B,GACzC,yBAAyB,CAqB3B;AAED;+EAC+E;AAC/E,eAAO,MAAM,6BAA6B,EAAE,yBAAiD,CAAC;AAE9F;;kCAEkC;AAClC,MAAM,MAAM,qBAAqB,GAAG,OAAO,CAAC,mBAAmB,EAAE,KAAK,GAAG,SAAS,CAAC,CAAC;AAEpF,MAAM,WAAW,gBAAgB;IAC/B;qDACiD;IACjD,IAAI,EAAE,aAAa,GAAG,IAAI,CAAC;IAC3B;;;wEAGoE;IACpE,QAAQ,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,qBAAqB,CAAA;KAAE,GAAG,IAAI,CAAC;CACrE;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,SAAS,aAAa,EAAE,EAClC,cAAc,EAAE,MAAM,GAAG,IAAI,EAC7B,YAAY,EAAE,yBAAyB,EACvC,OAAO,GAAE;IAAE,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAAO,GAC5C,OAAO,CAAC,gBAAgB,CAAC,CAgB3B;AAED;aACa;AACb,MAAM,WAAW,aAAc,SAAQ,aAAa;IAClD,aAAa,EAAE,mBAAmB,CAAC;CACpC;AAED;;;;;;;;GAQG;AACH,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,SAAS,aAAa,EAAE,EAClC,YAAY,EAAE,yBAAyB,GACtC,OAAO,CAAC,aAAa,EAAE,CAAC,CAO1B;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,SAAS,aAAa,EAAE,GACjC,qBAAqB,GAAG,IAAI,CAQ9B"}
@@ -0,0 +1,121 @@
1
+ import { TELO_SURFACE_VERSION, compareModuleVersions, isNewerModuleVersion, manifestCompatibility, parseModuleVersion, } from "@telorun/analyzer";
2
+ /**
3
+ * A compatibility check backed by `read`, memoized per module and version for
4
+ * the lifetime of the returned function.
5
+ *
6
+ * The cache is not an optimization detail — an IDE re-derives its upgrade
7
+ * affordances on every keystroke, and a published version's declared
8
+ * requirement is immutable, so refetching it would be pure waste against an
9
+ * answer that cannot have changed. In-flight promises are shared, so the
10
+ * concurrent lookups one file's imports produce collapse to one read each.
11
+ *
12
+ * The verdict is always the analyzer's {@link manifestCompatibility}: an IDE
13
+ * reports NO host versions, because it is not the machine that will run the
14
+ * manifest. Only the telo surface is checked here; a host requirement still
15
+ * surfaces at the load gate when the manifest is actually run.
16
+ */
17
+ export function createVersionCompatibility(read, teloVersion = TELO_SURFACE_VERSION) {
18
+ const cache = new Map();
19
+ return (baseRef, version) => {
20
+ const key = `${baseRef}@${version}`;
21
+ const cached = cache.get(key);
22
+ if (cached)
23
+ return cached;
24
+ const pending = read(baseRef, version)
25
+ .then((text) => (text ? manifestCompatibility(text, teloVersion) : "unknown"))
26
+ .catch(() => "unknown");
27
+ cache.set(key, pending);
28
+ // Only a DECIDED verdict is kept. What is immutable is a published version's
29
+ // declared requirement, not the network: caching `unknown` would let one
30
+ // offline moment disable the check for the rest of the session, leaving the
31
+ // UI reading as though every version had been checked and cleared. Dropped
32
+ // after it settles rather than before, so concurrent asks still share the
33
+ // one in-flight read.
34
+ void pending.then((verdict) => {
35
+ if (verdict === "unknown" && cache.get(key) === pending)
36
+ cache.delete(key);
37
+ });
38
+ return pending;
39
+ };
40
+ }
41
+ /** A check that answers `unknown` for everything — the honest reading for a
42
+ * host with no way to fetch a candidate manifest. Never blocks an upgrade. */
43
+ export const uncheckedVersionCompatibility = async () => "unknown";
44
+ /**
45
+ * The newest version whose declared requirement accepts this runtime, among
46
+ * those newer than `currentVersion`.
47
+ *
48
+ * Walks newest-first and stops at the first satisfied candidate, so the common
49
+ * case — the newest version is compatible — costs a single manifest read.
50
+ * Candidates older than the current version are never considered: an upgrade
51
+ * that walks backwards is a downgrade nobody asked for. Pass `null` for an
52
+ * import that does not exist yet, where every published version is a candidate
53
+ * and there is nothing to walk backwards from.
54
+ */
55
+ export async function selectCompatibleVersion(baseRef, versions, currentVersion, isCompatible, options = {}) {
56
+ const candidates = upgradeCandidates(versions, currentVersion, options);
57
+ if (candidates.length === 0)
58
+ return { best: null, heldBack: null };
59
+ const newest = candidates[0];
60
+ let firstReason = null;
61
+ for (const candidate of candidates) {
62
+ const verdict = await isCompatible(baseRef, candidate.version);
63
+ if (verdict === "too-new" || verdict === "unreadable") {
64
+ firstReason ??= verdict;
65
+ continue;
66
+ }
67
+ const held = candidate === newest ? null : { version: newest.version, reason: firstReason };
68
+ return { best: candidate, heldBack: held };
69
+ }
70
+ return { best: null, heldBack: { version: newest.version, reason: firstReason } };
71
+ }
72
+ /**
73
+ * Every version, each carrying its own verdict — what a deliberate pick needs.
74
+ *
75
+ * Unlike {@link selectCompatibleVersion} this cannot short-circuit: a picker
76
+ * marks entries it is not going to choose, so every entry has to be asked. That
77
+ * is why the two are separate operations rather than one with a flag — the
78
+ * automatic path must stay one read, and the picker is an explicit action on a
79
+ * single import whose reads are cached from then on.
80
+ */
81
+ export async function markVersionCompatibility(baseRef, versions, isCompatible) {
82
+ return Promise.all(versions.map(async (version) => ({
83
+ ...version,
84
+ compatibility: await isCompatible(baseRef, version.version),
85
+ })));
86
+ }
87
+ /**
88
+ * Why nothing in a marked list can be offered, or `null` when something can.
89
+ *
90
+ * A picker has to state this outright: a list where every row is marked
91
+ * explains nothing on its own, and the reader is left thinking the tool is
92
+ * broken. A reason rather than a boolean because the two rejections have
93
+ * different remedies — see {@link describeRemedy}. `unknown` counts as
94
+ * offerable: a version this host could not check is not one it may refuse.
95
+ */
96
+ export function noneRunnableReason(versions) {
97
+ if (versions.length === 0)
98
+ return null;
99
+ if (versions.some((v) => v.compatibility === "yes" || v.compatibility === "unknown")) {
100
+ return null;
101
+ }
102
+ // The newest blocked version's reason, in the list's own (newest-first)
103
+ // order: it is the one the reader is asking about.
104
+ return versions[0].compatibility;
105
+ }
106
+ /** Candidates strictly newer than `currentVersion`, newest first.
107
+ *
108
+ * Ordering and prerelease exclusion come from the analyzer's shared rule, so
109
+ * "is this import behind" and "what would it move to" cannot disagree. */
110
+ function upgradeCandidates(versions, currentVersion, options) {
111
+ return versions
112
+ .filter((v) => {
113
+ const parsed = parseModuleVersion(v.version);
114
+ if (parsed === null)
115
+ return false;
116
+ if (parsed.pre !== null && !options.includePrerelease)
117
+ return false;
118
+ return currentVersion === null || isNewerModuleVersion(v.version, currentVersion);
119
+ })
120
+ .sort((a, b) => compareModuleVersions(b.version, a.version) ?? 0);
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/ide-support",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Editor-host-agnostic IDE support (completions, diagnostic normalization) for Telo manifests.",
5
5
  "keywords": [
6
6
  "telo",
@@ -36,7 +36,7 @@
36
36
  "src/**"
37
37
  ],
38
38
  "dependencies": {
39
- "@telorun/analyzer": "0.62.1"
39
+ "@telorun/analyzer": "0.63.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^20.0.0",
@@ -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
+ }