@telorun/ide-support 0.7.9 → 0.8.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.
@@ -12,5 +12,5 @@ import type { DiagnosticContext } from "../types.js";
12
12
  * instead of spreading across every line of the surrounding map.
13
13
  * 4. Whole-line span at `sourceLine` when known.
14
14
  * 5. `(0,0)-(0,0)` as a last resort. Never undefined. */
15
- export declare function resolveRange(d: AnalysisDiagnostic, ctx: DiagnosticContext): Range;
15
+ export declare function resolveRange(d: AnalysisDiagnostic, ctx: Pick<DiagnosticContext, "positionIndex" | "sourceLine">): Range;
16
16
  //# sourceMappingURL=range-resolver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"range-resolver.d.ts","sourceRoot":"","sources":["../../src/diagnostics/range-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AACnE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAOrD;;;;;;;;;;;4DAW4D;AAC5D,wBAAgB,YAAY,CAAC,CAAC,EAAE,kBAAkB,EAAE,GAAG,EAAE,iBAAiB,GAAG,KAAK,CAuBjF"}
1
+ {"version":3,"file":"range-resolver.d.ts","sourceRoot":"","sources":["../../src/diagnostics/range-resolver.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AACnE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAOrD;;;;;;;;;;;4DAW4D;AAC5D,wBAAgB,YAAY,CAC1B,CAAC,EAAE,kBAAkB,EAIrB,GAAG,EAAE,IAAI,CAAC,iBAAiB,EAAE,eAAe,GAAG,YAAY,CAAC,GAC3D,KAAK,CAuBP"}
@@ -14,7 +14,11 @@ const ZERO_RANGE = {
14
14
  * instead of spreading across every line of the surrounding map.
15
15
  * 4. Whole-line span at `sourceLine` when known.
16
16
  * 5. `(0,0)-(0,0)` as a last resort. Never undefined. */
17
- export function resolveRange(d, ctx) {
17
+ export function resolveRange(d,
18
+ // Only the position half of a `DiagnosticContext` — a full one still
19
+ // satisfies this, and a caller holding just a located file (the CLI) does not
20
+ // have to invent a registry to reuse the one resolution rule.
21
+ ctx) {
18
22
  if (d.range)
19
23
  return d.range;
20
24
  const fieldPath = d.data?.path;
@@ -0,0 +1,78 @@
1
+ import { type AstDocument, type Range } from "@telorun/analyzer";
2
+ /** Version enumeration for one version-independent base ref, newest first.
3
+ *
4
+ * Narrower than the full `IdeEnvironmentAdapter` on purpose. It is the only
5
+ * environment capability an upgrade check needs, and a host that caches or
6
+ * throttles hub traffic wraps just this — a CodeLens re-resolves far more
7
+ * 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[]>;
11
+ /** A source edit a host applies verbatim to upgrade an import. Ranges never
12
+ * overlap, within an upgrade or across a batch, so a host may apply the whole
13
+ * set in one pass without ordering them. */
14
+ export interface ImportUpgradeEdit {
15
+ range: Range;
16
+ newText: string;
17
+ }
18
+ /** One import that has a newer version available. */
19
+ export interface ImportUpgrade {
20
+ alias: string;
21
+ /** The source as written, with any object-form `integrity:` folded in. */
22
+ source: string;
23
+ currentVersion: string;
24
+ latestVersion: string;
25
+ /** The source that replaces it: re-pointed at `latestVersion` with the
26
+ * integrity pin dropped. */
27
+ 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. */
31
+ wasPinned: boolean;
32
+ /** Span of the alias key — where a per-entry affordance anchors. */
33
+ keyRange: Range;
34
+ /** Apply all of these to upgrade this one import. */
35
+ edits: ImportUpgradeEdit[];
36
+ }
37
+ /** An import that IS behind but that this module declines to rewrite. Carries
38
+ * the same anchor and versions an {@link ImportUpgrade} does, so a host can
39
+ * render it in place of the upgrade affordance rather than leaving the author
40
+ * wondering why a stale import shows nothing at all. */
41
+ export interface ImportUpgradeSkip {
42
+ alias: string;
43
+ currentVersion: string;
44
+ latestVersion: string;
45
+ /** Span of the alias key — where a per-entry affordance anchors. */
46
+ keyRange: Range;
47
+ /** Author-facing sentence: what was not done, and what to run instead. */
48
+ reason: string;
49
+ }
50
+ export interface ImportUpgradeSet {
51
+ /** Span of the `imports:` key — where a summary affordance anchors. */
52
+ importsKeyRange: Range;
53
+ upgrades: ImportUpgrade[];
54
+ skipped: ImportUpgradeSkip[];
55
+ /** Base refs whose version lookup failed. Never thrown: one unreachable ref
56
+ * must not blank the affordances for every other import in the file. The
57
+ * host decides whether to log or surface these. */
58
+ failures: Array<{
59
+ baseRef: string;
60
+ message: string;
61
+ }>;
62
+ }
63
+ /**
64
+ * Find every `imports:` entry of a module document that names a version older
65
+ * than the newest one `listVersions` reports, and produce the source edits that
66
+ * re-point it.
67
+ *
68
+ * Skips what carries no upgradeable version: local path imports, bare URLs,
69
+ * untagged refs, and pins that are not SemVer (an OCI digest, a moving tag like
70
+ * `latest`) — `parseVersionedRef` and `isNewerModuleVersion` both decline to
71
+ * guess, so those simply produce no upgrade.
72
+ *
73
+ * Pure apart from `listVersions`: no filesystem, no direct network, no host
74
+ * API. Returns `undefined` when the file declares no module document or the
75
+ * module declares no `imports:`.
76
+ */
77
+ export declare function buildImportUpgrades(text: string, listVersions: ModuleVersionLookup, docs?: AstDocument[]): Promise<ImportUpgradeSet | undefined>;
78
+ //# sourceMappingURL=build-import-upgrades.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,95 @@
1
+ import { buildLineOffsets, isLocalPathSource, isNewerModuleVersion, newestModuleVersion, parseToAst, parseVersionedRef, withRefVersion, } from "@telorun/analyzer";
2
+ import { findImportEntries } from "./find-import-entries.js";
3
+ /**
4
+ * Find every `imports:` entry of a module document that names a version older
5
+ * than the newest one `listVersions` reports, and produce the source edits that
6
+ * re-point it.
7
+ *
8
+ * Skips what carries no upgradeable version: local path imports, bare URLs,
9
+ * untagged refs, and pins that are not SemVer (an OCI digest, a moving tag like
10
+ * `latest`) — `parseVersionedRef` and `isNewerModuleVersion` both decline to
11
+ * guess, so those simply produce no upgrade.
12
+ *
13
+ * Pure apart from `listVersions`: no filesystem, no direct network, no host
14
+ * API. Returns `undefined` when the file declares no module document or the
15
+ * module declares no `imports:`.
16
+ */
17
+ export async function buildImportUpgrades(text, listVersions, docs) {
18
+ const lineOffsets = buildLineOffsets(text);
19
+ const block = findImportEntries(text, docs ?? parseToAst(text), lineOffsets);
20
+ if (!block)
21
+ return undefined;
22
+ const candidates = block.entries.flatMap((entry) => {
23
+ if (isLocalPathSource(entry.source))
24
+ return [];
25
+ const ref = parseVersionedRef(entry.source);
26
+ return ref ? [{ entry, ref }] : [];
27
+ });
28
+ const failures = [];
29
+ const latest = await resolveLatest([...new Set(candidates.map((c) => c.ref.baseRef))], listVersions, failures);
30
+ const upgrades = [];
31
+ const skipped = [];
32
+ for (const { entry, ref } of candidates) {
33
+ const newest = latest.get(ref.baseRef);
34
+ if (!newest || !isNewerModuleVersion(newest, ref.version))
35
+ continue;
36
+ if (entry.integrityInline) {
37
+ skipped.push({
38
+ alias: entry.alias,
39
+ currentVersion: ref.version,
40
+ latestVersion: newest,
41
+ keyRange: entry.keyRange,
42
+ 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\`.`,
44
+ });
45
+ continue;
46
+ }
47
+ upgrades.push({
48
+ alias: entry.alias,
49
+ source: entry.source,
50
+ currentVersion: ref.version,
51
+ latestVersion: newest,
52
+ newSource: withRefVersion(entry.source, newest),
53
+ wasPinned: ref.integrity != null,
54
+ keyRange: entry.keyRange,
55
+ edits: buildEdits(entry, withRefVersion(entry.source, newest)),
56
+ });
57
+ }
58
+ return { importsKeyRange: block.keyRange, upgrades, skipped, failures };
59
+ }
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) {
66
+ const edits = [{ range: entry.sourceRange, newText: newSource }];
67
+ if (entry.integrityLineRange) {
68
+ edits.push({ range: entry.integrityLineRange, newText: "" });
69
+ }
70
+ return edits;
71
+ }
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
74
+ * rather than a wrong one. */
75
+ async function resolveLatest(baseRefs, listVersions, failures) {
76
+ const results = await Promise.all(baseRefs.map(async (baseRef) => {
77
+ try {
78
+ const versions = await listVersions(baseRef);
79
+ return { baseRef, newest: newestModuleVersion(versions) };
80
+ }
81
+ catch (err) {
82
+ failures.push({
83
+ baseRef,
84
+ message: err instanceof Error ? err.message : String(err),
85
+ });
86
+ return { baseRef, newest: undefined };
87
+ }
88
+ }));
89
+ const map = new Map();
90
+ for (const { baseRef, newest } of results) {
91
+ if (newest)
92
+ map.set(baseRef, newest);
93
+ }
94
+ return map;
95
+ }
@@ -0,0 +1,45 @@
1
+ import { type AstDocument, type Range } from "@telorun/analyzer";
2
+ /** One `imports:` map entry, located in the source text.
3
+ *
4
+ * `source` is the *folded* form — an object-form `integrity:` sibling is
5
+ * folded into the source string as a `#sha256-…` fragment, exactly as
6
+ * `inlineImportManifests` does, so callers reason about a single
7
+ * representation regardless of which shape the author wrote. */
8
+ export interface ImportEntry {
9
+ alias: string;
10
+ /** The source with any `integrity:` sibling folded in as a fragment. */
11
+ source: string;
12
+ /** Span of the alias key — where a per-entry affordance anchors. */
13
+ keyRange: Range;
14
+ /** Span of the source scalar's value: the entry value itself for the scalar
15
+ * shorthand, the `source:` value for the object form. Replacing this span
16
+ * re-points the import. */
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;
29
+ }
30
+ /** Where the `imports:` map lives in a module document. */
31
+ export interface ImportsBlock {
32
+ /** Span of the `imports:` key — where a summary affordance anchors. */
33
+ keyRange: Range;
34
+ entries: ImportEntry[];
35
+ }
36
+ /** Locate the `imports:` map of the file's module document (`Telo.Application`
37
+ * / `Telo.Library`). Returns `undefined` when the file declares no module doc
38
+ * or the doc has no `imports:` map — a partial file, or a module with no
39
+ * dependencies.
40
+ *
41
+ * Reads the AST rather than the analyzer's flattened manifests because the
42
+ * affordances built on top of this write back to the source: the exact span of
43
+ * each source scalar is the deliverable, not the resolved value. */
44
+ export declare function findImportEntries(text: string, docs: AstDocument[], lineOffsets: number[]): ImportsBlock | undefined;
45
+ //# sourceMappingURL=find-import-entries.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,102 @@
1
+ import { foldIntegrity, isModuleKind, offsetToPosition, } from "@telorun/analyzer";
2
+ /** Locate the `imports:` map of the file's module document (`Telo.Application`
3
+ * / `Telo.Library`). Returns `undefined` when the file declares no module doc
4
+ * or the doc has no `imports:` map — a partial file, or a module with no
5
+ * dependencies.
6
+ *
7
+ * Reads the AST rather than the analyzer's flattened manifests because the
8
+ * affordances built on top of this write back to the source: the exact span of
9
+ * each source scalar is the deliverable, not the resolved value. */
10
+ export function findImportEntries(text, docs, lineOffsets) {
11
+ for (const doc of docs) {
12
+ const root = doc.root;
13
+ if (!root || root.kind !== "map")
14
+ continue;
15
+ if (!isModuleKind(scalarString(mapGet(root, "kind"))))
16
+ continue;
17
+ const importsPair = root.entries.find((p) => p.key.kind === "scalar" && p.key.value === "imports");
18
+ if (!importsPair?.value || importsPair.value.kind !== "map")
19
+ return undefined;
20
+ return {
21
+ keyRange: toRange(importsPair.key, lineOffsets),
22
+ entries: importsPair.value.entries.flatMap((pair) => {
23
+ const alias = scalarString(pair.key);
24
+ if (alias === undefined || !pair.value)
25
+ return [];
26
+ const entry = readEntry(alias, pair.key, pair.value, text, lineOffsets);
27
+ return entry ? [entry] : [];
28
+ }),
29
+ };
30
+ }
31
+ return undefined;
32
+ }
33
+ /** Read one entry in either authored shape. Returns `undefined` for a malformed
34
+ * entry (an object with no string `source:`) — the module document's own
35
+ * schema validation already reports those against `imports.<Alias>.source`. */
36
+ function readEntry(alias, keyNode, valueNode, text, lineOffsets) {
37
+ const keyRange = toRange(keyNode, lineOffsets);
38
+ if (valueNode.kind === "scalar") {
39
+ const source = scalarString(valueNode);
40
+ if (source === undefined)
41
+ return undefined;
42
+ return { alias, source, keyRange, sourceRange: toRange(valueNode, lineOffsets) };
43
+ }
44
+ if (valueNode.kind !== "map")
45
+ return undefined;
46
+ const sourceNode = mapGet(valueNode, "source");
47
+ const source = scalarString(sourceNode);
48
+ if (sourceNode === undefined || source === undefined)
49
+ return undefined;
50
+ const integrityPair = valueNode.entries.find((p) => p.key.kind === "scalar" && p.key.value === "integrity");
51
+ const integrity = scalarString(integrityPair?.value);
52
+ const entry = {
53
+ alias,
54
+ source: foldIntegrity(source, integrity),
55
+ keyRange,
56
+ sourceRange: toRange(sourceNode, lineOffsets),
57
+ };
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;
64
+ }
65
+ return entry;
66
+ }
67
+ /** The whole-lines span covering `[start, end)` plus its trailing newline, or
68
+ * `undefined` when the span shares a line with other content — only leading
69
+ * indentation may precede it and nothing but spacing may follow. A flow-style
70
+ * map (`{source: …, integrity: …}`) fails this test, which is what keeps a
71
+ * line splice from eating a sibling field. */
72
+ function wholeLineSpan(start, end, text, lineOffsets) {
73
+ const lineStart = lineOffsets[offsetToPosition(start, lineOffsets).line];
74
+ if (text.slice(lineStart, start).trim() !== "")
75
+ return undefined;
76
+ const nextNewline = text.indexOf("\n", end);
77
+ const lineEnd = nextNewline === -1 ? text.length : nextNewline + 1;
78
+ if (text.slice(end, nextNewline === -1 ? text.length : nextNewline).trim() !== "") {
79
+ return undefined;
80
+ }
81
+ return {
82
+ start: offsetToPosition(lineStart, lineOffsets),
83
+ end: offsetToPosition(lineEnd, lineOffsets),
84
+ };
85
+ }
86
+ function toRange(node, lineOffsets) {
87
+ return {
88
+ start: offsetToPosition(node.range[0], lineOffsets),
89
+ end: offsetToPosition(node.range[1], lineOffsets),
90
+ };
91
+ }
92
+ function mapGet(node, key) {
93
+ return node.entries.find((p) => p.key.kind === "scalar" && p.key.value === key)?.value;
94
+ }
95
+ /** The node's value when it is a plain (untagged) string scalar. A `!cel` /
96
+ * `!ref` scalar resolves to a sentinel object, not a string, so it falls out
97
+ * here — an import source is never an expression. */
98
+ function scalarString(node) {
99
+ if (!node || node.kind !== "scalar")
100
+ return undefined;
101
+ return typeof node.value === "string" ? node.value : undefined;
102
+ }
@@ -0,0 +1,5 @@
1
+ export { buildImportUpgrades } from "./build-import-upgrades.js";
2
+ export type { ImportUpgrade, ImportUpgradeEdit, ImportUpgradeSet, ImportUpgradeSkip, ModuleVersionLookup, } from "./build-import-upgrades.js";
3
+ export { findImportEntries } from "./find-import-entries.js";
4
+ export type { ImportEntry, ImportsBlock } from "./find-import-entries.js";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,2 @@
1
+ export { buildImportUpgrades } from "./build-import-upgrades.js";
2
+ export { findImportEntries } from "./find-import-entries.js";
package/dist/index.d.ts CHANGED
@@ -4,4 +4,5 @@ export * from "./diagnostics/index.js";
4
4
  export * from "./hover/index.js";
5
5
  export * from "./semantic-tokens/index.js";
6
6
  export * from "./definition/index.js";
7
+ export * from "./import-upgrades/index.js";
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,kBAAkB,CAAC;AACjC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC"}
package/dist/index.js CHANGED
@@ -4,3 +4,4 @@ export * from "./diagnostics/index.js";
4
4
  export * from "./hover/index.js";
5
5
  export * from "./semantic-tokens/index.js";
6
6
  export * from "./definition/index.js";
7
+ export * from "./import-upgrades/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/ide-support",
3
- "version": "0.7.9",
3
+ "version": "0.8.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.49.0"
39
+ "@telorun/analyzer": "0.50.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^20.0.0",
@@ -18,7 +18,13 @@ const ZERO_RANGE: Range = {
18
18
  * instead of spreading across every line of the surrounding map.
19
19
  * 4. Whole-line span at `sourceLine` when known.
20
20
  * 5. `(0,0)-(0,0)` as a last resort. Never undefined. */
21
- export function resolveRange(d: AnalysisDiagnostic, ctx: DiagnosticContext): Range {
21
+ export function resolveRange(
22
+ d: AnalysisDiagnostic,
23
+ // Only the position half of a `DiagnosticContext` — a full one still
24
+ // satisfies this, and a caller holding just a located file (the CLI) does not
25
+ // have to invent a registry to reuse the one resolution rule.
26
+ ctx: Pick<DiagnosticContext, "positionIndex" | "sourceLine">,
27
+ ): Range {
22
28
  if (d.range) return d.range;
23
29
 
24
30
  const fieldPath = (d.data as { path?: string } | undefined)?.path;
@@ -0,0 +1,190 @@
1
+ import {
2
+ buildLineOffsets,
3
+ isLocalPathSource,
4
+ isNewerModuleVersion,
5
+ newestModuleVersion,
6
+ parseToAst,
7
+ parseVersionedRef,
8
+ withRefVersion,
9
+ type AstDocument,
10
+ type Range,
11
+ } from "@telorun/analyzer";
12
+ import { findImportEntries, type ImportEntry } from "./find-import-entries.js";
13
+
14
+ /** Version enumeration for one version-independent base ref, newest first.
15
+ *
16
+ * Narrower than the full `IdeEnvironmentAdapter` on purpose. It is the only
17
+ * environment capability an upgrade check needs, and a host that caches or
18
+ * throttles hub traffic wraps just this — a CodeLens re-resolves far more
19
+ * 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[]>;
23
+
24
+ /** A source edit a host applies verbatim to upgrade an import. Ranges never
25
+ * overlap, within an upgrade or across a batch, so a host may apply the whole
26
+ * set in one pass without ordering them. */
27
+ export interface ImportUpgradeEdit {
28
+ range: Range;
29
+ newText: string;
30
+ }
31
+
32
+ /** One import that has a newer version available. */
33
+ export interface ImportUpgrade {
34
+ alias: string;
35
+ /** The source as written, with any object-form `integrity:` folded in. */
36
+ source: string;
37
+ currentVersion: string;
38
+ latestVersion: string;
39
+ /** The source that replaces it: re-pointed at `latestVersion` with the
40
+ * integrity pin dropped. */
41
+ 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. */
45
+ wasPinned: boolean;
46
+ /** Span of the alias key — where a per-entry affordance anchors. */
47
+ keyRange: Range;
48
+ /** Apply all of these to upgrade this one import. */
49
+ edits: ImportUpgradeEdit[];
50
+ }
51
+
52
+ /** An import that IS behind but that this module declines to rewrite. Carries
53
+ * the same anchor and versions an {@link ImportUpgrade} does, so a host can
54
+ * render it in place of the upgrade affordance rather than leaving the author
55
+ * wondering why a stale import shows nothing at all. */
56
+ export interface ImportUpgradeSkip {
57
+ alias: string;
58
+ currentVersion: string;
59
+ latestVersion: string;
60
+ /** Span of the alias key — where a per-entry affordance anchors. */
61
+ keyRange: Range;
62
+ /** Author-facing sentence: what was not done, and what to run instead. */
63
+ reason: string;
64
+ }
65
+
66
+ export interface ImportUpgradeSet {
67
+ /** Span of the `imports:` key — where a summary affordance anchors. */
68
+ importsKeyRange: Range;
69
+ upgrades: ImportUpgrade[];
70
+ skipped: ImportUpgradeSkip[];
71
+ /** Base refs whose version lookup failed. Never thrown: one unreachable ref
72
+ * must not blank the affordances for every other import in the file. The
73
+ * host decides whether to log or surface these. */
74
+ failures: Array<{ baseRef: string; message: string }>;
75
+ }
76
+
77
+ /**
78
+ * Find every `imports:` entry of a module document that names a version older
79
+ * than the newest one `listVersions` reports, and produce the source edits that
80
+ * re-point it.
81
+ *
82
+ * Skips what carries no upgradeable version: local path imports, bare URLs,
83
+ * untagged refs, and pins that are not SemVer (an OCI digest, a moving tag like
84
+ * `latest`) — `parseVersionedRef` and `isNewerModuleVersion` both decline to
85
+ * guess, so those simply produce no upgrade.
86
+ *
87
+ * Pure apart from `listVersions`: no filesystem, no direct network, no host
88
+ * API. Returns `undefined` when the file declares no module document or the
89
+ * module declares no `imports:`.
90
+ */
91
+ export async function buildImportUpgrades(
92
+ text: string,
93
+ listVersions: ModuleVersionLookup,
94
+ docs?: AstDocument[],
95
+ ): Promise<ImportUpgradeSet | undefined> {
96
+ const lineOffsets = buildLineOffsets(text);
97
+ const block = findImportEntries(text, docs ?? parseToAst(text), lineOffsets);
98
+ if (!block) return undefined;
99
+
100
+ const candidates = block.entries.flatMap((entry) => {
101
+ if (isLocalPathSource(entry.source)) return [];
102
+ const ref = parseVersionedRef(entry.source);
103
+ return ref ? [{ entry, ref }] : [];
104
+ });
105
+
106
+ const failures: Array<{ baseRef: string; message: string }> = [];
107
+ const latest = await resolveLatest(
108
+ [...new Set(candidates.map((c) => c.ref.baseRef))],
109
+ listVersions,
110
+ failures,
111
+ );
112
+
113
+ const upgrades: ImportUpgrade[] = [];
114
+ const skipped: ImportUpgradeSkip[] = [];
115
+
116
+ for (const { entry, ref } of candidates) {
117
+ const newest = latest.get(ref.baseRef);
118
+ if (!newest || !isNewerModuleVersion(newest, ref.version)) continue;
119
+
120
+ if (entry.integrityInline) {
121
+ skipped.push({
122
+ alias: entry.alias,
123
+ currentVersion: ref.version,
124
+ latestVersion: newest,
125
+ keyRange: entry.keyRange,
126
+ reason:
127
+ `'${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\`.`,
129
+ });
130
+ continue;
131
+ }
132
+
133
+ upgrades.push({
134
+ alias: entry.alias,
135
+ source: entry.source,
136
+ currentVersion: ref.version,
137
+ latestVersion: newest,
138
+ newSource: withRefVersion(entry.source, newest),
139
+ wasPinned: ref.integrity != null,
140
+ keyRange: entry.keyRange,
141
+ edits: buildEdits(entry, withRefVersion(entry.source, newest)),
142
+ });
143
+ }
144
+
145
+ return { importsKeyRange: block.keyRange, upgrades, skipped, failures };
146
+ }
147
+
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[] {
154
+ const edits: ImportUpgradeEdit[] = [{ range: entry.sourceRange, newText: newSource }];
155
+ if (entry.integrityLineRange) {
156
+ edits.push({ range: entry.integrityLineRange, newText: "" });
157
+ }
158
+ return edits;
159
+ }
160
+
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
163
+ * rather than a wrong one. */
164
+ async function resolveLatest(
165
+ baseRefs: string[],
166
+ listVersions: ModuleVersionLookup,
167
+ failures: Array<{ baseRef: string; message: string }>,
168
+ ): Promise<Map<string, string>> {
169
+ const results = await Promise.all(
170
+ baseRefs.map(async (baseRef) => {
171
+ try {
172
+ const versions = await listVersions(baseRef);
173
+ return { baseRef, newest: newestModuleVersion(versions) };
174
+ } catch (err) {
175
+ failures.push({
176
+ baseRef,
177
+ message: err instanceof Error ? err.message : String(err),
178
+ });
179
+ return { baseRef, newest: undefined };
180
+ }
181
+ }),
182
+ );
183
+
184
+ const map = new Map<string, string>();
185
+ for (const { baseRef, newest } of results) {
186
+ if (newest) map.set(baseRef, newest);
187
+ }
188
+ return map;
189
+ }
190
+
@@ -0,0 +1,175 @@
1
+ import {
2
+ foldIntegrity,
3
+ isModuleKind,
4
+ offsetToPosition,
5
+ type AstDocument,
6
+ type AstMap,
7
+ type AstNode,
8
+ type Range,
9
+ } from "@telorun/analyzer";
10
+
11
+ /** One `imports:` map entry, located in the source text.
12
+ *
13
+ * `source` is the *folded* form — an object-form `integrity:` sibling is
14
+ * folded into the source string as a `#sha256-…` fragment, exactly as
15
+ * `inlineImportManifests` does, so callers reason about a single
16
+ * representation regardless of which shape the author wrote. */
17
+ export interface ImportEntry {
18
+ alias: string;
19
+ /** The source with any `integrity:` sibling folded in as a fragment. */
20
+ source: string;
21
+ /** Span of the alias key — where a per-entry affordance anchors. */
22
+ keyRange: Range;
23
+ /** Span of the source scalar's value: the entry value itself for the scalar
24
+ * shorthand, the `source:` value for the object form. Replacing this span
25
+ * re-points the import. */
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;
38
+ }
39
+
40
+ /** Where the `imports:` map lives in a module document. */
41
+ export interface ImportsBlock {
42
+ /** Span of the `imports:` key — where a summary affordance anchors. */
43
+ keyRange: Range;
44
+ entries: ImportEntry[];
45
+ }
46
+
47
+ /** Locate the `imports:` map of the file's module document (`Telo.Application`
48
+ * / `Telo.Library`). Returns `undefined` when the file declares no module doc
49
+ * or the doc has no `imports:` map — a partial file, or a module with no
50
+ * dependencies.
51
+ *
52
+ * Reads the AST rather than the analyzer's flattened manifests because the
53
+ * affordances built on top of this write back to the source: the exact span of
54
+ * each source scalar is the deliverable, not the resolved value. */
55
+ export function findImportEntries(
56
+ text: string,
57
+ docs: AstDocument[],
58
+ lineOffsets: number[],
59
+ ): ImportsBlock | undefined {
60
+ for (const doc of docs) {
61
+ const root = doc.root;
62
+ if (!root || root.kind !== "map") continue;
63
+ if (!isModuleKind(scalarString(mapGet(root, "kind")))) continue;
64
+
65
+ const importsPair = root.entries.find(
66
+ (p) => p.key.kind === "scalar" && p.key.value === "imports",
67
+ );
68
+ if (!importsPair?.value || importsPair.value.kind !== "map") return undefined;
69
+
70
+ return {
71
+ keyRange: toRange(importsPair.key, lineOffsets),
72
+ entries: importsPair.value.entries.flatMap((pair) => {
73
+ const alias = scalarString(pair.key);
74
+ if (alias === undefined || !pair.value) return [];
75
+ const entry = readEntry(alias, pair.key, pair.value, text, lineOffsets);
76
+ return entry ? [entry] : [];
77
+ }),
78
+ };
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /** Read one entry in either authored shape. Returns `undefined` for a malformed
84
+ * entry (an object with no string `source:`) — the module document's own
85
+ * schema validation already reports those against `imports.<Alias>.source`. */
86
+ function readEntry(
87
+ alias: string,
88
+ keyNode: AstNode,
89
+ valueNode: AstNode,
90
+ text: string,
91
+ lineOffsets: number[],
92
+ ): ImportEntry | undefined {
93
+ const keyRange = toRange(keyNode, lineOffsets);
94
+
95
+ if (valueNode.kind === "scalar") {
96
+ const source = scalarString(valueNode);
97
+ if (source === undefined) return undefined;
98
+ return { alias, source, keyRange, sourceRange: toRange(valueNode, lineOffsets) };
99
+ }
100
+
101
+ if (valueNode.kind !== "map") return undefined;
102
+ const sourceNode = mapGet(valueNode, "source");
103
+ const source = scalarString(sourceNode);
104
+ if (sourceNode === undefined || source === undefined) return undefined;
105
+
106
+ const integrityPair = valueNode.entries.find(
107
+ (p) => p.key.kind === "scalar" && p.key.value === "integrity",
108
+ );
109
+ const integrity = scalarString(integrityPair?.value);
110
+
111
+ const entry: ImportEntry = {
112
+ alias,
113
+ source: foldIntegrity(source, integrity),
114
+ keyRange,
115
+ sourceRange: toRange(sourceNode, lineOffsets),
116
+ };
117
+
118
+ 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;
127
+ }
128
+
129
+ return entry;
130
+ }
131
+
132
+ /** The whole-lines span covering `[start, end)` plus its trailing newline, or
133
+ * `undefined` when the span shares a line with other content — only leading
134
+ * indentation may precede it and nothing but spacing may follow. A flow-style
135
+ * map (`{source: …, integrity: …}`) fails this test, which is what keeps a
136
+ * line splice from eating a sibling field. */
137
+ function wholeLineSpan(
138
+ start: number,
139
+ end: number,
140
+ text: string,
141
+ lineOffsets: number[],
142
+ ): Range | undefined {
143
+ const lineStart = lineOffsets[offsetToPosition(start, lineOffsets).line];
144
+ if (text.slice(lineStart, start).trim() !== "") return undefined;
145
+
146
+ const nextNewline = text.indexOf("\n", end);
147
+ const lineEnd = nextNewline === -1 ? text.length : nextNewline + 1;
148
+ if (text.slice(end, nextNewline === -1 ? text.length : nextNewline).trim() !== "") {
149
+ return undefined;
150
+ }
151
+
152
+ return {
153
+ start: offsetToPosition(lineStart, lineOffsets),
154
+ end: offsetToPosition(lineEnd, lineOffsets),
155
+ };
156
+ }
157
+
158
+ function toRange(node: AstNode, lineOffsets: number[]): Range {
159
+ return {
160
+ start: offsetToPosition(node.range[0], lineOffsets),
161
+ end: offsetToPosition(node.range[1], lineOffsets),
162
+ };
163
+ }
164
+
165
+ function mapGet(node: AstMap, key: string): AstNode | undefined {
166
+ return node.entries.find((p) => p.key.kind === "scalar" && p.key.value === key)?.value;
167
+ }
168
+
169
+ /** The node's value when it is a plain (untagged) string scalar. A `!cel` /
170
+ * `!ref` scalar resolves to a sentinel object, not a string, so it falls out
171
+ * here — an import source is never an expression. */
172
+ function scalarString(node: AstNode | undefined): string | undefined {
173
+ if (!node || node.kind !== "scalar") return undefined;
174
+ return typeof node.value === "string" ? node.value : undefined;
175
+ }
@@ -0,0 +1,10 @@
1
+ export { buildImportUpgrades } from "./build-import-upgrades.js";
2
+ export type {
3
+ ImportUpgrade,
4
+ ImportUpgradeEdit,
5
+ ImportUpgradeSet,
6
+ ImportUpgradeSkip,
7
+ ModuleVersionLookup,
8
+ } from "./build-import-upgrades.js";
9
+ export { findImportEntries } from "./find-import-entries.js";
10
+ export type { ImportEntry, ImportsBlock } from "./find-import-entries.js";
package/src/index.ts CHANGED
@@ -4,3 +4,4 @@ export * from "./diagnostics/index.js";
4
4
  export * from "./hover/index.js";
5
5
  export * from "./semantic-tokens/index.js";
6
6
  export * from "./definition/index.js";
7
+ export * from "./import-upgrades/index.js";