@telorun/ide-support 0.9.0 → 0.10.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,13 +1,28 @@
1
1
  import { type AstDocument, type Range } from "@telorun/analyzer";
2
+ /** One version of a module, as the hub reports it.
3
+ *
4
+ * `integrity` is the import pin for exactly this version (`sha256-<base64url>`
5
+ * over whatever the owning transport verifies its own reads against). It is
6
+ * absent for a version the hub tracked before it recorded pins, and for a ref
7
+ * no transport can hash — never an error, just no pin to write. */
8
+ export interface ModuleVersion {
9
+ version: string;
10
+ integrity?: string;
11
+ }
2
12
  /** Version enumeration for one version-independent base ref, newest first.
3
13
  *
4
14
  * Narrower than the full `IdeEnvironmentAdapter` on purpose. It is the only
5
15
  * environment capability an upgrade check needs, and a host that caches or
6
16
  * throttles hub traffic wraps just this — a CodeLens re-resolves far more
7
17
  * often than a completion popup opens, so the refresh cadence is the host's
8
- * policy, not this module's. A host backed by the hub passes
9
- * `adapter.listVersionsForRef`. */
10
- export type ModuleVersionLookup = (baseRef: string) => Promise<string[]>;
18
+ * policy, not this module's.
19
+ *
20
+ * Deliberately NOT `adapter.listVersionsForRef`, which answers `string[]`:
21
+ * completion offers names, an upgrade writes a pin, so the two want different
22
+ * things from one route. A host backed by the hub fetches
23
+ * `GET /module/versions` and passes the body through
24
+ * {@link parseModuleVersions}. */
25
+ export type ModuleVersionLookup = (baseRef: string) => Promise<ModuleVersion[]>;
11
26
  /** A source edit a host applies verbatim to upgrade an import. Ranges never
12
27
  * overlap, within an upgrade or across a batch, so a host may apply the whole
13
28
  * set in one pass without ordering them. */
@@ -22,18 +37,38 @@ export interface ImportUpgrade {
22
37
  source: string;
23
38
  currentVersion: string;
24
39
  latestVersion: string;
25
- /** The source that replaces it: re-pointed at `latestVersion` with the
26
- * integrity pin dropped. */
40
+ /** What the import resolves to after the edits, in the same folded form as
41
+ * `source` — re-pointed at `latestVersion`, carrying the new pin as a
42
+ * `#sha256-…` fragment when one was available. Folded rather than literal
43
+ * because where the pin physically lands depends on the shape the author
44
+ * wrote (fragment vs `integrity:` sibling), and a host showing this as a
45
+ * preview wants the resolved import, not one of two spellings of it. */
27
46
  newSource: string;
28
- /** True when the replaced import carried a pin. A host that cannot recompute
29
- * the hash should say so — the pin covers the version being replaced, and
30
- * `telo upgrade` re-pins either shape. */
47
+ /** True when the replaced import carried a pin. */
31
48
  wasPinned: boolean;
49
+ /** True when the edits leave the import pinned to `latestVersion`. False
50
+ * means no pin was available for the target version — a host should say so
51
+ * when `wasPinned`, since the rewrite silently drops a hash the author had. */
52
+ repinned: boolean;
32
53
  /** Span of the alias key — where a per-entry affordance anchors. */
33
54
  keyRange: Range;
34
55
  /** Apply all of these to upgrade this one import. */
35
56
  edits: ImportUpgradeEdit[];
36
57
  }
58
+ /** One import that is already at the newest version but carries no integrity
59
+ * pin, and for which a pin is available. Mirrors what `telo upgrade` does with
60
+ * `ensurePinned`: a rarely-released module whose version never moves would
61
+ * otherwise stay unpinned forever, because nothing ever offers to rewrite it. */
62
+ export interface ImportPin {
63
+ alias: string;
64
+ source: string;
65
+ version: string;
66
+ /** The source that replaces it: unchanged but for the appended pin. */
67
+ newSource: string;
68
+ /** Span of the alias key — where a per-entry affordance anchors. */
69
+ keyRange: Range;
70
+ edits: ImportUpgradeEdit[];
71
+ }
37
72
  /** An import that IS behind but that this module declines to rewrite. Carries
38
73
  * the same anchor and versions an {@link ImportUpgrade} does, so a host can
39
74
  * render it in place of the upgrade affordance rather than leaving the author
@@ -51,6 +86,7 @@ export interface ImportUpgradeSet {
51
86
  /** Span of the `imports:` key — where a summary affordance anchors. */
52
87
  importsKeyRange: Range;
53
88
  upgrades: ImportUpgrade[];
89
+ pins: ImportPin[];
54
90
  skipped: ImportUpgradeSkip[];
55
91
  /** Base refs whose version lookup failed. Never thrown: one unreachable ref
56
92
  * must not blank the affordances for every other import in the file. The
@@ -63,7 +99,8 @@ export interface ImportUpgradeSet {
63
99
  /**
64
100
  * Find every `imports:` entry of a module document that names a version older
65
101
  * than the newest one `listVersions` reports, and produce the source edits that
66
- * re-point it.
102
+ * re-point it — plus every entry already at the newest version that carries no
103
+ * integrity pin, and the edits that pin it.
67
104
  *
68
105
  * Skips what carries no upgradeable version: local path imports, bare URLs,
69
106
  * untagged refs, and pins that are not SemVer (an OCI digest, a moving tag like
@@ -1 +1 @@
1
- {"version":3,"file":"build-import-upgrades.d.ts","sourceRoot":"","sources":["../../src/import-upgrades/build-import-upgrades.ts"],"names":[],"mappings":"AAAA,OAAO,EAQL,KAAK,WAAW,EAChB,KAAK,KAAK,EACX,MAAM,mBAAmB,CAAC;AAG3B;;;;;;;oCAOoC;AACpC,MAAM,MAAM,mBAAmB,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;AAEzE;;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;iCAC6B;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB;;+CAE2C;IAC3C,SAAS,EAAE,OAAO,CAAC;IACnB,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;IAChB,qDAAqD;IACrD,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,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;;;;;;;;;;;;;GAaG;AACH,wBAAsB,mBAAmB,CACvC,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,mBAAmB,EACjC,IAAI,CAAC,EAAE,WAAW,EAAE,GACnB,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAmDvC"}
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,9 +1,10 @@
1
- import { buildLineOffsets, isLocalPathSource, isNewerModuleVersion, newestModuleVersion, parseToAst, parseVersionedRef, withRefVersion, } from "@telorun/analyzer";
1
+ import { buildLineOffsets, foldIntegrity, isCanonicalIntegrity, isLocalPathSource, isNewerModuleVersion, isSameModuleVersion, newestModuleVersion, parseModuleVersion, parseToAst, parseVersionedRef, withRefVersion, } from "@telorun/analyzer";
2
2
  import { findImportEntries } from "./find-import-entries.js";
3
3
  /**
4
4
  * Find every `imports:` entry of a module document that names a version older
5
5
  * than the newest one `listVersions` reports, and produce the source edits that
6
- * re-point it.
6
+ * re-point it — plus every entry already at the newest version that carries no
7
+ * integrity pin, and the edits that pin it.
7
8
  *
8
9
  * Skips what carries no upgradeable version: local path imports, bare URLs,
9
10
  * untagged refs, and pins that are not SemVer (an OCI digest, a moving tag like
@@ -26,70 +27,134 @@ export async function buildImportUpgrades(text, listVersions, docs) {
26
27
  return ref ? [{ entry, ref }] : [];
27
28
  });
28
29
  const failures = [];
29
- const latest = await resolveLatest([...new Set(candidates.map((c) => c.ref.baseRef))], listVersions, failures);
30
+ const known = await resolveVersions([...new Set(candidates.map((c) => c.ref.baseRef))], listVersions, failures);
30
31
  const upgrades = [];
32
+ const pins = [];
31
33
  const skipped = [];
32
34
  for (const { entry, ref } of candidates) {
33
- const newest = latest.get(ref.baseRef);
34
- if (!newest || !isNewerModuleVersion(newest, ref.version))
35
+ const versions = known.get(ref.baseRef);
36
+ if (!versions)
35
37
  continue;
36
- if (entry.integrityInline) {
38
+ const newest = newestModuleVersion(versions.map((v) => v.version));
39
+ if (!newest)
40
+ continue;
41
+ if (!isNewerModuleVersion(newest, ref.version)) {
42
+ const pin = pinInPlace(entry, ref.version, ref.integrity, versions);
43
+ if (pin)
44
+ pins.push(pin);
45
+ continue;
46
+ }
47
+ const integrity = integrityFor(versions, newest);
48
+ // A stale pin that can be neither replaced nor removed is the one case left
49
+ // that has to be declined: re-pointing the source while leaving a hash for
50
+ // the version it replaced turns the next install into a tamper error.
51
+ if (ref.integrity && !integrity && entry.integrity && !entry.integrity.lineRange) {
37
52
  skipped.push({
38
53
  alias: entry.alias,
39
54
  currentVersion: ref.version,
40
55
  latestVersion: newest,
41
56
  keyRange: entry.keyRange,
42
57
  reason: `'${entry.alias}' carries an inline 'integrity:' that shares a line with other ` +
43
- `fields, so the stale pin cannot be removed by a line edit. Run \`telo upgrade\`.`,
58
+ `fields, and no pin is published for ${newest}, so the stale one cannot be ` +
59
+ `removed by a line edit. Run \`telo upgrade\`.`,
44
60
  });
45
61
  continue;
46
62
  }
63
+ const newSource = withRefVersion(entry.source, newest);
47
64
  upgrades.push({
48
65
  alias: entry.alias,
49
66
  source: entry.source,
50
67
  currentVersion: ref.version,
51
68
  latestVersion: newest,
52
- newSource: withRefVersion(entry.source, newest),
69
+ newSource: foldIntegrity(newSource, integrity),
53
70
  wasPinned: ref.integrity != null,
71
+ repinned: integrity != null,
54
72
  keyRange: entry.keyRange,
55
- edits: buildEdits(entry, withRefVersion(entry.source, newest)),
73
+ edits: buildEdits(entry, newSource, integrity),
56
74
  });
57
75
  }
58
- return { importsKeyRange: block.keyRange, upgrades, skipped, failures };
76
+ return { importsKeyRange: block.keyRange, upgrades, pins, skipped, failures };
59
77
  }
60
- /** Re-point the source scalar, and delete a now-stale object-form `integrity:`
61
- * line. `withRefVersion` already strips an inline `#sha256-…` fragment, so the
62
- * scalar shorthand needs no second edit. Dropping the pin is not optional: it
63
- * hashes the `telo.yaml` of the version being replaced, so carrying it onto a
64
- * different version turns the next install into a tamper error. */
65
- function buildEdits(entry, newSource) {
78
+ /** The edits that re-point an entry at `newSource` and settle its pin.
79
+ *
80
+ * Where the pin is written follows the shape the author chose: an entry with an
81
+ * `integrity:` sibling keeps it (its value is replaced in place, which works in
82
+ * block and flow style alike), and everything else carries the pin inside the
83
+ * source as a `#sha256-…` fragment — the form `telo upgrade` writes.
84
+ *
85
+ * With no pin available the sibling is deleted instead. That is not optional:
86
+ * it hashes the `telo.yaml` of the version being replaced, so carrying it onto
87
+ * a different version turns the next install into a tamper error.
88
+ * `withRefVersion` has already stripped a fragment-form pin for the same
89
+ * reason, so the scalar shorthand needs no second edit. */
90
+ function buildEdits(entry, newSource, integrity) {
91
+ if (!entry.integrity) {
92
+ return [{ range: entry.sourceRange, newText: foldIntegrity(newSource, integrity) }];
93
+ }
66
94
  const edits = [{ range: entry.sourceRange, newText: newSource }];
67
- if (entry.integrityLineRange) {
68
- edits.push({ range: entry.integrityLineRange, newText: "" });
95
+ if (integrity) {
96
+ edits.push({ range: entry.integrity.valueRange, newText: integrity });
97
+ }
98
+ else if (entry.integrity.lineRange) {
99
+ edits.push({ range: entry.integrity.lineRange, newText: "" });
69
100
  }
70
101
  return edits;
71
102
  }
72
- /** Newest version per base ref, fetched once each. A ref whose lookup rejects
73
- * is recorded in `failures` and left out of the map, so it yields no upgrade
103
+ /** Pin an entry that is already at the newest version, or `undefined` when
104
+ * there is nothing to do it is pinned already, the hub published no pin for
105
+ * the version it names, or that version is not one this module will order.
106
+ *
107
+ * The version gate matters: a moving tag (`latest`) or a digest names bytes
108
+ * that are expected to change, so pinning it would break the next release
109
+ * rather than protect it. `telo upgrade` draws the same line. */
110
+ function pinInPlace(entry, version, existingIntegrity, versions) {
111
+ if (existingIntegrity != null || entry.integrity)
112
+ return undefined;
113
+ if (parseModuleVersion(version) === null)
114
+ return undefined;
115
+ const integrity = integrityFor(versions, version);
116
+ if (!integrity)
117
+ return undefined;
118
+ const newSource = foldIntegrity(entry.source, integrity);
119
+ return {
120
+ alias: entry.alias,
121
+ source: entry.source,
122
+ version,
123
+ newSource,
124
+ keyRange: entry.keyRange,
125
+ edits: [{ range: entry.sourceRange, newText: newSource }],
126
+ };
127
+ }
128
+ /** The pin the hub reports for one version, matched by SemVer identity rather
129
+ * than string equality so a `v`-prefixed tag on either side still lines up.
130
+ *
131
+ * Re-checked here even though the host's parse already did: this value is
132
+ * spliced into the author's YAML, and a caller reaching the builder through
133
+ * its own `ModuleVersionLookup` never passed through that parse. */
134
+ function integrityFor(versions, version) {
135
+ const integrity = versions.find((v) => isSameModuleVersion(v.version, version))?.integrity;
136
+ return isCanonicalIntegrity(integrity) ? integrity : undefined;
137
+ }
138
+ /** Versions per base ref, fetched once each. A ref whose lookup rejects is
139
+ * recorded in `failures` and left out of the map, so it yields no upgrade
74
140
  * rather than a wrong one. */
75
- async function resolveLatest(baseRefs, listVersions, failures) {
141
+ async function resolveVersions(baseRefs, listVersions, failures) {
76
142
  const results = await Promise.all(baseRefs.map(async (baseRef) => {
77
143
  try {
78
- const versions = await listVersions(baseRef);
79
- return { baseRef, newest: newestModuleVersion(versions) };
144
+ return { baseRef, versions: await listVersions(baseRef) };
80
145
  }
81
146
  catch (err) {
82
147
  failures.push({
83
148
  baseRef,
84
149
  message: err instanceof Error ? err.message : String(err),
85
150
  });
86
- return { baseRef, newest: undefined };
151
+ return { baseRef, versions: undefined };
87
152
  }
88
153
  }));
89
154
  const map = new Map();
90
- for (const { baseRef, newest } of results) {
91
- if (newest)
92
- map.set(baseRef, newest);
155
+ for (const { baseRef, versions } of results) {
156
+ if (versions)
157
+ map.set(baseRef, versions);
93
158
  }
94
159
  return map;
95
160
  }
@@ -15,17 +15,23 @@ export interface ImportEntry {
15
15
  * shorthand, the `source:` value for the object form. Replacing this span
16
16
  * re-points the import. */
17
17
  sourceRange: Range;
18
- /** Whole-line span of an object-form `integrity:` entry, including its
19
- * trailing newline, so a caller can delete the line. Absent for the scalar
20
- * shorthand (where the pin rides inside `sourceRange`) and for an entry that
21
- * declares no `integrity:`. */
22
- integrityLineRange?: Range;
23
- /** Set when the entry carries an `integrity:` sibling that does NOT occupy
24
- * whole lines of its own (a flow-style `{source: …, integrity: …}` map).
25
- * Deleting it would need a structural rewrite rather than a line splice, so
26
- * a caller that cannot leave the pin behind must skip this entry rather
27
- * than re-point it and strand a hash for the version it replaced. */
28
- integrityInline?: boolean;
18
+ /** Where an object-form `integrity:` sibling sits, when the entry declares
19
+ * one. Absent for the scalar shorthand there the pin rides inside
20
+ * `sourceRange` as a `#sha256-…` fragment and for an unpinned entry. */
21
+ integrity?: ImportEntryIntegrity;
22
+ }
23
+ /** The two spans a caller needs to act on an object-form `integrity:`: one to
24
+ * re-pin it, one to remove it. They are separate because a flow-style map
25
+ * (`{source: …, integrity: …}`) supports the first and not the second. */
26
+ export interface ImportEntryIntegrity {
27
+ /** Span of the hash value itself. Replacing it re-pins the entry in the shape
28
+ * the author wrote, in any YAML style. */
29
+ valueRange: Range;
30
+ /** Whole-line span of the `integrity:` entry including its trailing newline,
31
+ * so a caller with no replacement hash can delete the line outright. Absent
32
+ * when the pair shares a line with other content, where removing it would
33
+ * need a structural rewrite rather than a line splice. */
34
+ lineRange?: Range;
29
35
  }
30
36
  /** Where the `imports:` map lives in a module document. */
31
37
  export interface ImportsBlock {
@@ -1 +1 @@
1
- {"version":3,"file":"find-import-entries.d.ts","sourceRoot":"","sources":["../../src/import-upgrades/find-import-entries.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,WAAW,EAGhB,KAAK,KAAK,EACX,MAAM,mBAAmB,CAAC;AAE3B;;;;;iEAKiE;AACjE,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;IAChB;;gCAE4B;IAC5B,WAAW,EAAE,KAAK,CAAC;IACnB;;;oCAGgC;IAChC,kBAAkB,CAAC,EAAE,KAAK,CAAC;IAC3B;;;;0EAIsE;IACtE,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,2DAA2D;AAC3D,MAAM,WAAW,YAAY;IAC3B,uEAAuE;IACvE,QAAQ,EAAE,KAAK,CAAC;IAChB,OAAO,EAAE,WAAW,EAAE,CAAC;CACxB;AAED;;;;;;;qEAOqE;AACrE,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,WAAW,EAAE,EACnB,WAAW,EAAE,MAAM,EAAE,GACpB,YAAY,GAAG,SAAS,CAsB1B"}
1
+ {"version":3,"file":"find-import-entries.d.ts","sourceRoot":"","sources":["../../src/import-upgrades/find-import-entries.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,WAAW,EAGhB,KAAK,KAAK,EACX,MAAM,mBAAmB,CAAC;AAE3B;;;;;iEAKiE;AACjE,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,QAAQ,EAAE,KAAK,CAAC;IAChB;;gCAE4B;IAC5B,WAAW,EAAE,KAAK,CAAC;IACnB;;+EAE2E;IAC3E,SAAS,CAAC,EAAE,oBAAoB,CAAC;CAClC;AAED;;2EAE2E;AAC3E,MAAM,WAAW,oBAAoB;IACnC;+CAC2C;IAC3C,UAAU,EAAE,KAAK,CAAC;IAClB;;;+DAG2D;IAC3D,SAAS,CAAC,EAAE,KAAK,CAAC;CACnB;AAED,2DAA2D;AAC3D,MAAM,WAAW,YAAY;IAC3B,uEAAuE;IACvE,QAAQ,EAAE,KAAK,CAAC;IAChB,OAAO,EAAE,WAAW,EAAE,CAAC;CACxB;AAED;;;;;;;qEAOqE;AACrE,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,WAAW,EAAE,EACnB,WAAW,EAAE,MAAM,EAAE,GACpB,YAAY,GAAG,SAAS,CAsB1B"}
@@ -56,11 +56,10 @@ function readEntry(alias, keyNode, valueNode, text, lineOffsets) {
56
56
  sourceRange: toRange(sourceNode, lineOffsets),
57
57
  };
58
58
  if (integrityPair?.value && integrity !== undefined) {
59
- const lineSpan = wholeLineSpan(integrityPair.key.range[0], integrityPair.value.range[1], text, lineOffsets);
60
- if (lineSpan)
61
- entry.integrityLineRange = lineSpan;
62
- else
63
- entry.integrityInline = true;
59
+ entry.integrity = {
60
+ valueRange: toRange(integrityPair.value, lineOffsets),
61
+ lineRange: wholeLineSpan(integrityPair.key.range[0], integrityPair.value.range[1], text, lineOffsets),
62
+ };
64
63
  }
65
64
  return entry;
66
65
  }
@@ -1,5 +1,6 @@
1
1
  export { buildImportUpgrades } from "./build-import-upgrades.js";
2
- export type { ImportUpgrade, ImportUpgradeEdit, ImportUpgradeSet, ImportUpgradeSkip, ModuleVersionLookup, } from "./build-import-upgrades.js";
2
+ export type { ImportPin, ImportUpgrade, ImportUpgradeEdit, ImportUpgradeSet, ImportUpgradeSkip, ModuleVersion, ModuleVersionLookup, } from "./build-import-upgrades.js";
3
+ export { parseModuleVersions } from "./parse-module-versions.js";
3
4
  export { findImportEntries } from "./find-import-entries.js";
4
- export type { ImportEntry, ImportsBlock } from "./find-import-entries.js";
5
+ export type { ImportEntry, ImportEntryIntegrity, ImportsBlock, } from "./find-import-entries.js";
5
6
  //# 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,aAAa,EACb,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,GACpB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC"}
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,2 +1,3 @@
1
1
  export { buildImportUpgrades } from "./build-import-upgrades.js";
2
+ export { parseModuleVersions } from "./parse-module-versions.js";
2
3
  export { findImportEntries } from "./find-import-entries.js";
@@ -0,0 +1,19 @@
1
+ import type { ModuleVersion } from "./build-import-upgrades.js";
2
+ /**
3
+ * Read the hub's `GET /module/versions` payload into {@link ModuleVersion}s,
4
+ * newest first (the route's own ordering, preserved).
5
+ *
6
+ * Pure — the host owns the `fetch`, as everything else in this package does.
7
+ * It lives here rather than in each host because the shape is this package's:
8
+ * three hosts parsing the same route by hand is how one of them was left
9
+ * filtering for strings after the route started returning objects, which
10
+ * TypeScript could not catch behind the response cast and which surfaced only
11
+ * as a silently empty version list.
12
+ *
13
+ * Tolerant by design: an entry with no usable `version` is dropped rather than
14
+ * throwing, and an `integrity` that is not a canonical `sha256-<base64url>` is
15
+ * discarded rather than carried — a pin is spliced into the author's YAML, so a
16
+ * malformed one has to become "no pin", never corrupt text.
17
+ */
18
+ export declare function parseModuleVersions(payload: unknown): ModuleVersion[];
19
+ //# sourceMappingURL=parse-module-versions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parse-module-versions.d.ts","sourceRoot":"","sources":["../../src/import-upgrades/parse-module-versions.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAEhE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,aAAa,EAAE,CAUrE"}
@@ -0,0 +1,29 @@
1
+ import { isCanonicalIntegrity } from "@telorun/analyzer";
2
+ /**
3
+ * Read the hub's `GET /module/versions` payload into {@link ModuleVersion}s,
4
+ * newest first (the route's own ordering, preserved).
5
+ *
6
+ * Pure — the host owns the `fetch`, as everything else in this package does.
7
+ * It lives here rather than in each host because the shape is this package's:
8
+ * three hosts parsing the same route by hand is how one of them was left
9
+ * filtering for strings after the route started returning objects, which
10
+ * TypeScript could not catch behind the response cast and which surfaced only
11
+ * as a silently empty version list.
12
+ *
13
+ * Tolerant by design: an entry with no usable `version` is dropped rather than
14
+ * throwing, and an `integrity` that is not a canonical `sha256-<base64url>` is
15
+ * discarded rather than carried — a pin is spliced into the author's YAML, so a
16
+ * malformed one has to become "no pin", never corrupt text.
17
+ */
18
+ export function parseModuleVersions(payload) {
19
+ const versions = payload?.versions;
20
+ if (!Array.isArray(versions))
21
+ return [];
22
+ return versions.flatMap((entry) => {
23
+ const version = entry?.version;
24
+ if (typeof version !== "string" || version === "")
25
+ return [];
26
+ const integrity = entry.integrity;
27
+ return [isCanonicalIntegrity(integrity) ? { version, integrity } : { version }];
28
+ });
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/ide-support",
3
- "version": "0.9.0",
3
+ "version": "0.10.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.51.0"
39
+ "@telorun/analyzer": "0.52.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^20.0.0",
@@ -1,8 +1,12 @@
1
1
  import {
2
2
  buildLineOffsets,
3
+ foldIntegrity,
4
+ isCanonicalIntegrity,
3
5
  isLocalPathSource,
4
6
  isNewerModuleVersion,
7
+ isSameModuleVersion,
5
8
  newestModuleVersion,
9
+ parseModuleVersion,
6
10
  parseToAst,
7
11
  parseVersionedRef,
8
12
  withRefVersion,
@@ -11,15 +15,31 @@ import {
11
15
  } from "@telorun/analyzer";
12
16
  import { findImportEntries, type ImportEntry } from "./find-import-entries.js";
13
17
 
18
+ /** One version of a module, as the hub reports it.
19
+ *
20
+ * `integrity` is the import pin for exactly this version (`sha256-<base64url>`
21
+ * over whatever the owning transport verifies its own reads against). It is
22
+ * absent for a version the hub tracked before it recorded pins, and for a ref
23
+ * no transport can hash — never an error, just no pin to write. */
24
+ export interface ModuleVersion {
25
+ version: string;
26
+ integrity?: string;
27
+ }
28
+
14
29
  /** Version enumeration for one version-independent base ref, newest first.
15
30
  *
16
31
  * Narrower than the full `IdeEnvironmentAdapter` on purpose. It is the only
17
32
  * environment capability an upgrade check needs, and a host that caches or
18
33
  * throttles hub traffic wraps just this — a CodeLens re-resolves far more
19
34
  * often than a completion popup opens, so the refresh cadence is the host's
20
- * policy, not this module's. A host backed by the hub passes
21
- * `adapter.listVersionsForRef`. */
22
- export type ModuleVersionLookup = (baseRef: string) => Promise<string[]>;
35
+ * policy, not this module's.
36
+ *
37
+ * Deliberately NOT `adapter.listVersionsForRef`, which answers `string[]`:
38
+ * completion offers names, an upgrade writes a pin, so the two want different
39
+ * things from one route. A host backed by the hub fetches
40
+ * `GET /module/versions` and passes the body through
41
+ * {@link parseModuleVersions}. */
42
+ export type ModuleVersionLookup = (baseRef: string) => Promise<ModuleVersion[]>;
23
43
 
24
44
  /** A source edit a host applies verbatim to upgrade an import. Ranges never
25
45
  * overlap, within an upgrade or across a batch, so a host may apply the whole
@@ -36,19 +56,40 @@ export interface ImportUpgrade {
36
56
  source: string;
37
57
  currentVersion: string;
38
58
  latestVersion: string;
39
- /** The source that replaces it: re-pointed at `latestVersion` with the
40
- * integrity pin dropped. */
59
+ /** What the import resolves to after the edits, in the same folded form as
60
+ * `source` — re-pointed at `latestVersion`, carrying the new pin as a
61
+ * `#sha256-…` fragment when one was available. Folded rather than literal
62
+ * because where the pin physically lands depends on the shape the author
63
+ * wrote (fragment vs `integrity:` sibling), and a host showing this as a
64
+ * preview wants the resolved import, not one of two spellings of it. */
41
65
  newSource: string;
42
- /** True when the replaced import carried a pin. A host that cannot recompute
43
- * the hash should say so — the pin covers the version being replaced, and
44
- * `telo upgrade` re-pins either shape. */
66
+ /** True when the replaced import carried a pin. */
45
67
  wasPinned: boolean;
68
+ /** True when the edits leave the import pinned to `latestVersion`. False
69
+ * means no pin was available for the target version — a host should say so
70
+ * when `wasPinned`, since the rewrite silently drops a hash the author had. */
71
+ repinned: boolean;
46
72
  /** Span of the alias key — where a per-entry affordance anchors. */
47
73
  keyRange: Range;
48
74
  /** Apply all of these to upgrade this one import. */
49
75
  edits: ImportUpgradeEdit[];
50
76
  }
51
77
 
78
+ /** One import that is already at the newest version but carries no integrity
79
+ * pin, and for which a pin is available. Mirrors what `telo upgrade` does with
80
+ * `ensurePinned`: a rarely-released module whose version never moves would
81
+ * otherwise stay unpinned forever, because nothing ever offers to rewrite it. */
82
+ export interface ImportPin {
83
+ alias: string;
84
+ source: string;
85
+ version: string;
86
+ /** The source that replaces it: unchanged but for the appended pin. */
87
+ newSource: string;
88
+ /** Span of the alias key — where a per-entry affordance anchors. */
89
+ keyRange: Range;
90
+ edits: ImportUpgradeEdit[];
91
+ }
92
+
52
93
  /** An import that IS behind but that this module declines to rewrite. Carries
53
94
  * the same anchor and versions an {@link ImportUpgrade} does, so a host can
54
95
  * render it in place of the upgrade affordance rather than leaving the author
@@ -67,6 +108,7 @@ export interface ImportUpgradeSet {
67
108
  /** Span of the `imports:` key — where a summary affordance anchors. */
68
109
  importsKeyRange: Range;
69
110
  upgrades: ImportUpgrade[];
111
+ pins: ImportPin[];
70
112
  skipped: ImportUpgradeSkip[];
71
113
  /** Base refs whose version lookup failed. Never thrown: one unreachable ref
72
114
  * must not blank the affordances for every other import in the file. The
@@ -77,7 +119,8 @@ export interface ImportUpgradeSet {
77
119
  /**
78
120
  * Find every `imports:` entry of a module document that names a version older
79
121
  * than the newest one `listVersions` reports, and produce the source edits that
80
- * re-point it.
122
+ * re-point it — plus every entry already at the newest version that carries no
123
+ * integrity pin, and the edits that pin it.
81
124
  *
82
125
  * Skips what carries no upgradeable version: local path imports, bare URLs,
83
126
  * untagged refs, and pins that are not SemVer (an OCI digest, a moving tag like
@@ -104,20 +147,33 @@ export async function buildImportUpgrades(
104
147
  });
105
148
 
106
149
  const failures: Array<{ baseRef: string; message: string }> = [];
107
- const latest = await resolveLatest(
150
+ const known = await resolveVersions(
108
151
  [...new Set(candidates.map((c) => c.ref.baseRef))],
109
152
  listVersions,
110
153
  failures,
111
154
  );
112
155
 
113
156
  const upgrades: ImportUpgrade[] = [];
157
+ const pins: ImportPin[] = [];
114
158
  const skipped: ImportUpgradeSkip[] = [];
115
159
 
116
160
  for (const { entry, ref } of candidates) {
117
- const newest = latest.get(ref.baseRef);
118
- if (!newest || !isNewerModuleVersion(newest, ref.version)) continue;
161
+ const versions = known.get(ref.baseRef);
162
+ if (!versions) continue;
163
+ const newest = newestModuleVersion(versions.map((v) => v.version));
164
+ if (!newest) continue;
119
165
 
120
- if (entry.integrityInline) {
166
+ if (!isNewerModuleVersion(newest, ref.version)) {
167
+ const pin = pinInPlace(entry, ref.version, ref.integrity, versions);
168
+ if (pin) pins.push(pin);
169
+ continue;
170
+ }
171
+
172
+ const integrity = integrityFor(versions, newest);
173
+ // A stale pin that can be neither replaced nor removed is the one case left
174
+ // that has to be declined: re-pointing the source while leaving a hash for
175
+ // the version it replaced turns the next install into a tamper error.
176
+ if (ref.integrity && !integrity && entry.integrity && !entry.integrity.lineRange) {
121
177
  skipped.push({
122
178
  alias: entry.alias,
123
179
  currentVersion: ref.version,
@@ -125,66 +181,124 @@ export async function buildImportUpgrades(
125
181
  keyRange: entry.keyRange,
126
182
  reason:
127
183
  `'${entry.alias}' carries an inline 'integrity:' that shares a line with other ` +
128
- `fields, so the stale pin cannot be removed by a line edit. Run \`telo upgrade\`.`,
184
+ `fields, and no pin is published for ${newest}, so the stale one cannot be ` +
185
+ `removed by a line edit. Run \`telo upgrade\`.`,
129
186
  });
130
187
  continue;
131
188
  }
132
189
 
190
+ const newSource = withRefVersion(entry.source, newest);
133
191
  upgrades.push({
134
192
  alias: entry.alias,
135
193
  source: entry.source,
136
194
  currentVersion: ref.version,
137
195
  latestVersion: newest,
138
- newSource: withRefVersion(entry.source, newest),
196
+ newSource: foldIntegrity(newSource, integrity),
139
197
  wasPinned: ref.integrity != null,
198
+ repinned: integrity != null,
140
199
  keyRange: entry.keyRange,
141
- edits: buildEdits(entry, withRefVersion(entry.source, newest)),
200
+ edits: buildEdits(entry, newSource, integrity),
142
201
  });
143
202
  }
144
203
 
145
- return { importsKeyRange: block.keyRange, upgrades, skipped, failures };
204
+ return { importsKeyRange: block.keyRange, upgrades, pins, skipped, failures };
146
205
  }
147
206
 
148
- /** Re-point the source scalar, and delete a now-stale object-form `integrity:`
149
- * line. `withRefVersion` already strips an inline `#sha256-…` fragment, so the
150
- * scalar shorthand needs no second edit. Dropping the pin is not optional: it
151
- * hashes the `telo.yaml` of the version being replaced, so carrying it onto a
152
- * different version turns the next install into a tamper error. */
153
- function buildEdits(entry: ImportEntry, newSource: string): ImportUpgradeEdit[] {
207
+ /** The edits that re-point an entry at `newSource` and settle its pin.
208
+ *
209
+ * Where the pin is written follows the shape the author chose: an entry with an
210
+ * `integrity:` sibling keeps it (its value is replaced in place, which works in
211
+ * block and flow style alike), and everything else carries the pin inside the
212
+ * source as a `#sha256-…` fragment — the form `telo upgrade` writes.
213
+ *
214
+ * With no pin available the sibling is deleted instead. That is not optional:
215
+ * it hashes the `telo.yaml` of the version being replaced, so carrying it onto
216
+ * a different version turns the next install into a tamper error.
217
+ * `withRefVersion` has already stripped a fragment-form pin for the same
218
+ * reason, so the scalar shorthand needs no second edit. */
219
+ function buildEdits(
220
+ entry: ImportEntry,
221
+ newSource: string,
222
+ integrity: string | undefined,
223
+ ): ImportUpgradeEdit[] {
224
+ if (!entry.integrity) {
225
+ return [{ range: entry.sourceRange, newText: foldIntegrity(newSource, integrity) }];
226
+ }
227
+
154
228
  const edits: ImportUpgradeEdit[] = [{ range: entry.sourceRange, newText: newSource }];
155
- if (entry.integrityLineRange) {
156
- edits.push({ range: entry.integrityLineRange, newText: "" });
229
+ if (integrity) {
230
+ edits.push({ range: entry.integrity.valueRange, newText: integrity });
231
+ } else if (entry.integrity.lineRange) {
232
+ edits.push({ range: entry.integrity.lineRange, newText: "" });
157
233
  }
158
234
  return edits;
159
235
  }
160
236
 
161
- /** Newest version per base ref, fetched once each. A ref whose lookup rejects
162
- * is recorded in `failures` and left out of the map, so it yields no upgrade
237
+ /** Pin an entry that is already at the newest version, or `undefined` when
238
+ * there is nothing to do it is pinned already, the hub published no pin for
239
+ * the version it names, or that version is not one this module will order.
240
+ *
241
+ * The version gate matters: a moving tag (`latest`) or a digest names bytes
242
+ * that are expected to change, so pinning it would break the next release
243
+ * rather than protect it. `telo upgrade` draws the same line. */
244
+ function pinInPlace(
245
+ entry: ImportEntry,
246
+ version: string,
247
+ existingIntegrity: string | undefined,
248
+ versions: ModuleVersion[],
249
+ ): ImportPin | undefined {
250
+ if (existingIntegrity != null || entry.integrity) return undefined;
251
+ if (parseModuleVersion(version) === null) return undefined;
252
+ const integrity = integrityFor(versions, version);
253
+ if (!integrity) return undefined;
254
+
255
+ const newSource = foldIntegrity(entry.source, integrity);
256
+ return {
257
+ alias: entry.alias,
258
+ source: entry.source,
259
+ version,
260
+ newSource,
261
+ keyRange: entry.keyRange,
262
+ edits: [{ range: entry.sourceRange, newText: newSource }],
263
+ };
264
+ }
265
+
266
+ /** The pin the hub reports for one version, matched by SemVer identity rather
267
+ * than string equality so a `v`-prefixed tag on either side still lines up.
268
+ *
269
+ * Re-checked here even though the host's parse already did: this value is
270
+ * spliced into the author's YAML, and a caller reaching the builder through
271
+ * its own `ModuleVersionLookup` never passed through that parse. */
272
+ function integrityFor(versions: ModuleVersion[], version: string): string | undefined {
273
+ const integrity = versions.find((v) => isSameModuleVersion(v.version, version))?.integrity;
274
+ return isCanonicalIntegrity(integrity) ? integrity : undefined;
275
+ }
276
+
277
+ /** Versions per base ref, fetched once each. A ref whose lookup rejects is
278
+ * recorded in `failures` and left out of the map, so it yields no upgrade
163
279
  * rather than a wrong one. */
164
- async function resolveLatest(
280
+ async function resolveVersions(
165
281
  baseRefs: string[],
166
282
  listVersions: ModuleVersionLookup,
167
283
  failures: Array<{ baseRef: string; message: string }>,
168
- ): Promise<Map<string, string>> {
284
+ ): Promise<Map<string, ModuleVersion[]>> {
169
285
  const results = await Promise.all(
170
286
  baseRefs.map(async (baseRef) => {
171
287
  try {
172
- const versions = await listVersions(baseRef);
173
- return { baseRef, newest: newestModuleVersion(versions) };
288
+ return { baseRef, versions: await listVersions(baseRef) };
174
289
  } catch (err) {
175
290
  failures.push({
176
291
  baseRef,
177
292
  message: err instanceof Error ? err.message : String(err),
178
293
  });
179
- return { baseRef, newest: undefined };
294
+ return { baseRef, versions: undefined };
180
295
  }
181
296
  }),
182
297
  );
183
298
 
184
- const map = new Map<string, string>();
185
- for (const { baseRef, newest } of results) {
186
- if (newest) map.set(baseRef, newest);
299
+ const map = new Map<string, ModuleVersion[]>();
300
+ for (const { baseRef, versions } of results) {
301
+ if (versions) map.set(baseRef, versions);
187
302
  }
188
303
  return map;
189
304
  }
190
-
@@ -24,17 +24,24 @@ export interface ImportEntry {
24
24
  * shorthand, the `source:` value for the object form. Replacing this span
25
25
  * re-points the import. */
26
26
  sourceRange: Range;
27
- /** Whole-line span of an object-form `integrity:` entry, including its
28
- * trailing newline, so a caller can delete the line. Absent for the scalar
29
- * shorthand (where the pin rides inside `sourceRange`) and for an entry that
30
- * declares no `integrity:`. */
31
- integrityLineRange?: Range;
32
- /** Set when the entry carries an `integrity:` sibling that does NOT occupy
33
- * whole lines of its own (a flow-style `{source: …, integrity: …}` map).
34
- * Deleting it would need a structural rewrite rather than a line splice, so
35
- * a caller that cannot leave the pin behind must skip this entry rather
36
- * than re-point it and strand a hash for the version it replaced. */
37
- integrityInline?: boolean;
27
+ /** Where an object-form `integrity:` sibling sits, when the entry declares
28
+ * one. Absent for the scalar shorthand there the pin rides inside
29
+ * `sourceRange` as a `#sha256-…` fragment and for an unpinned entry. */
30
+ integrity?: ImportEntryIntegrity;
31
+ }
32
+
33
+ /** The two spans a caller needs to act on an object-form `integrity:`: one to
34
+ * re-pin it, one to remove it. They are separate because a flow-style map
35
+ * (`{source: …, integrity: …}`) supports the first and not the second. */
36
+ export interface ImportEntryIntegrity {
37
+ /** Span of the hash value itself. Replacing it re-pins the entry in the shape
38
+ * the author wrote, in any YAML style. */
39
+ valueRange: Range;
40
+ /** Whole-line span of the `integrity:` entry including its trailing newline,
41
+ * so a caller with no replacement hash can delete the line outright. Absent
42
+ * when the pair shares a line with other content, where removing it would
43
+ * need a structural rewrite rather than a line splice. */
44
+ lineRange?: Range;
38
45
  }
39
46
 
40
47
  /** Where the `imports:` map lives in a module document. */
@@ -116,14 +123,15 @@ function readEntry(
116
123
  };
117
124
 
118
125
  if (integrityPair?.value && integrity !== undefined) {
119
- const lineSpan = wholeLineSpan(
120
- integrityPair.key.range[0],
121
- integrityPair.value.range[1],
122
- text,
123
- lineOffsets,
124
- );
125
- if (lineSpan) entry.integrityLineRange = lineSpan;
126
- else entry.integrityInline = true;
126
+ entry.integrity = {
127
+ valueRange: toRange(integrityPair.value, lineOffsets),
128
+ lineRange: wholeLineSpan(
129
+ integrityPair.key.range[0],
130
+ integrityPair.value.range[1],
131
+ text,
132
+ lineOffsets,
133
+ ),
134
+ };
127
135
  }
128
136
 
129
137
  return entry;
@@ -1,10 +1,17 @@
1
1
  export { buildImportUpgrades } from "./build-import-upgrades.js";
2
2
  export type {
3
+ ImportPin,
3
4
  ImportUpgrade,
4
5
  ImportUpgradeEdit,
5
6
  ImportUpgradeSet,
6
7
  ImportUpgradeSkip,
8
+ ModuleVersion,
7
9
  ModuleVersionLookup,
8
10
  } from "./build-import-upgrades.js";
11
+ export { parseModuleVersions } from "./parse-module-versions.js";
9
12
  export { findImportEntries } from "./find-import-entries.js";
10
- export type { ImportEntry, ImportsBlock } from "./find-import-entries.js";
13
+ export type {
14
+ ImportEntry,
15
+ ImportEntryIntegrity,
16
+ ImportsBlock,
17
+ } from "./find-import-entries.js";
@@ -0,0 +1,30 @@
1
+ import { isCanonicalIntegrity } from "@telorun/analyzer";
2
+ import type { ModuleVersion } from "./build-import-upgrades.js";
3
+
4
+ /**
5
+ * Read the hub's `GET /module/versions` payload into {@link ModuleVersion}s,
6
+ * newest first (the route's own ordering, preserved).
7
+ *
8
+ * Pure — the host owns the `fetch`, as everything else in this package does.
9
+ * It lives here rather than in each host because the shape is this package's:
10
+ * three hosts parsing the same route by hand is how one of them was left
11
+ * filtering for strings after the route started returning objects, which
12
+ * TypeScript could not catch behind the response cast and which surfaced only
13
+ * as a silently empty version list.
14
+ *
15
+ * Tolerant by design: an entry with no usable `version` is dropped rather than
16
+ * throwing, and an `integrity` that is not a canonical `sha256-<base64url>` is
17
+ * discarded rather than carried — a pin is spliced into the author's YAML, so a
18
+ * malformed one has to become "no pin", never corrupt text.
19
+ */
20
+ export function parseModuleVersions(payload: unknown): ModuleVersion[] {
21
+ const versions = (payload as { versions?: unknown } | null | undefined)?.versions;
22
+ if (!Array.isArray(versions)) return [];
23
+
24
+ return versions.flatMap((entry) => {
25
+ const version = (entry as { version?: unknown } | null)?.version;
26
+ if (typeof version !== "string" || version === "") return [];
27
+ const integrity = (entry as { integrity?: unknown }).integrity;
28
+ return [isCanonicalIntegrity(integrity) ? { version, integrity } : { version }];
29
+ });
30
+ }