@telorun/analyzer 0.56.1 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/analyzer.d.ts +5 -0
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +232 -94
  4. package/dist/cel-environment.d.ts.map +1 -1
  5. package/dist/cel-environment.js +6 -3
  6. package/dist/definition-registry.d.ts +5 -0
  7. package/dist/definition-registry.d.ts.map +1 -1
  8. package/dist/definition-registry.js +9 -0
  9. package/dist/index.d.ts +14 -4
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +8 -3
  12. package/dist/invocation-contract.d.ts +21 -13
  13. package/dist/invocation-contract.d.ts.map +1 -1
  14. package/dist/invocation-contract.js +43 -46
  15. package/dist/loaded-types.d.ts +13 -0
  16. package/dist/loaded-types.d.ts.map +1 -1
  17. package/dist/manifest-loader.d.ts +8 -1
  18. package/dist/manifest-loader.d.ts.map +1 -1
  19. package/dist/manifest-loader.js +33 -6
  20. package/dist/manifest-visitor.d.ts +4 -0
  21. package/dist/manifest-visitor.d.ts.map +1 -1
  22. package/dist/manifest-visitor.js +3 -3
  23. package/dist/migrations/driver.d.ts +69 -0
  24. package/dist/migrations/driver.d.ts.map +1 -0
  25. package/dist/migrations/driver.js +200 -0
  26. package/dist/migrations/entries/index.d.ts +3 -0
  27. package/dist/migrations/entries/index.d.ts.map +1 -0
  28. package/dist/migrations/entries/index.js +9 -0
  29. package/dist/migrations/entries/normalize-value-types.json +48 -0
  30. package/dist/migrations/entries/ref-slot-scalar-type.json +20 -0
  31. package/dist/migrations/entry-data.d.ts +25 -0
  32. package/dist/migrations/entry-data.d.ts.map +1 -0
  33. package/dist/migrations/entry-data.js +171 -0
  34. package/dist/migrations/index.d.ts +22 -0
  35. package/dist/migrations/index.d.ts.map +1 -0
  36. package/dist/migrations/index.js +19 -0
  37. package/dist/migrations/match.d.ts +162 -0
  38. package/dist/migrations/match.d.ts.map +1 -0
  39. package/dist/migrations/match.js +295 -0
  40. package/dist/migrations/patch.d.ts +68 -0
  41. package/dist/migrations/patch.d.ts.map +1 -0
  42. package/dist/migrations/patch.js +236 -0
  43. package/dist/migrations/provenance.d.ts +25 -0
  44. package/dist/migrations/provenance.d.ts.map +1 -0
  45. package/dist/migrations/provenance.js +118 -0
  46. package/dist/migrations/registry.d.ts +35 -0
  47. package/dist/migrations/registry.d.ts.map +1 -0
  48. package/dist/migrations/registry.js +35 -0
  49. package/dist/migrations/report.d.ts +28 -0
  50. package/dist/migrations/report.d.ts.map +1 -0
  51. package/dist/migrations/report.js +144 -0
  52. package/dist/migrations/types.d.ts +115 -0
  53. package/dist/migrations/types.d.ts.map +1 -0
  54. package/dist/migrations/types.js +24 -0
  55. package/dist/migrations/yaml-edit.d.ts +21 -0
  56. package/dist/migrations/yaml-edit.d.ts.map +1 -0
  57. package/dist/migrations/yaml-edit.js +245 -0
  58. package/dist/module-file-claims.d.ts +65 -0
  59. package/dist/module-file-claims.d.ts.map +1 -0
  60. package/dist/module-file-claims.js +106 -0
  61. package/dist/parse-loaded-file.d.ts +8 -0
  62. package/dist/parse-loaded-file.d.ts.map +1 -1
  63. package/dist/parse-loaded-file.js +22 -13
  64. package/dist/resolve-schema-type-refs.d.ts +29 -12
  65. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  66. package/dist/resolve-schema-type-refs.js +89 -16
  67. package/dist/schema-compat.d.ts +35 -9
  68. package/dist/schema-compat.d.ts.map +1 -1
  69. package/dist/schema-compat.js +181 -54
  70. package/dist/schema-region.d.ts +33 -0
  71. package/dist/schema-region.d.ts.map +1 -0
  72. package/dist/schema-region.js +45 -0
  73. package/dist/types.d.ts +51 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js +6 -0
  76. package/dist/validate-cel-context.d.ts.map +1 -1
  77. package/dist/validate-cel-context.js +29 -3
  78. package/dist/validate-include-placement.d.ts +26 -0
  79. package/dist/validate-include-placement.d.ts.map +1 -0
  80. package/dist/validate-include-placement.js +67 -0
  81. package/dist/validate-step-inputs.d.ts +8 -1
  82. package/dist/validate-step-inputs.d.ts.map +1 -1
  83. package/dist/validate-step-inputs.js +47 -2
  84. package/dist/validate-throws-coverage.d.ts.map +1 -1
  85. package/dist/validate-throws-coverage.js +15 -12
  86. package/dist/validate-value-type-slots.d.ts +43 -0
  87. package/dist/validate-value-type-slots.d.ts.map +1 -0
  88. package/dist/validate-value-type-slots.js +142 -0
  89. package/dist/value-type-keyword.d.ts +60 -0
  90. package/dist/value-type-keyword.d.ts.map +1 -0
  91. package/dist/value-type-keyword.js +119 -0
  92. package/dist/yaml-source-edit.d.ts +56 -0
  93. package/dist/yaml-source-edit.d.ts.map +1 -0
  94. package/dist/yaml-source-edit.js +93 -0
  95. package/package.json +4 -3
  96. package/src/analyzer.ts +276 -129
  97. package/src/cel-environment.ts +6 -3
  98. package/src/definition-registry.ts +10 -0
  99. package/src/index.ts +31 -3
  100. package/src/invocation-contract.ts +43 -48
  101. package/src/loaded-types.ts +13 -0
  102. package/src/manifest-loader.ts +37 -6
  103. package/src/manifest-visitor.ts +11 -3
  104. package/src/migrations/driver.ts +262 -0
  105. package/src/migrations/entries/index.ts +10 -0
  106. package/src/migrations/entries/normalize-value-types.json +48 -0
  107. package/src/migrations/entries/ref-slot-scalar-type.json +20 -0
  108. package/src/migrations/entry-data.ts +207 -0
  109. package/src/migrations/index.ts +22 -0
  110. package/src/migrations/match.ts +382 -0
  111. package/src/migrations/patch.ts +265 -0
  112. package/src/migrations/provenance.ts +167 -0
  113. package/src/migrations/registry.ts +40 -0
  114. package/src/migrations/report.ts +169 -0
  115. package/src/migrations/types.ts +144 -0
  116. package/src/migrations/yaml-edit.ts +260 -0
  117. package/src/module-file-claims.ts +168 -0
  118. package/src/parse-loaded-file.ts +31 -12
  119. package/src/resolve-schema-type-refs.ts +93 -16
  120. package/src/schema-compat.ts +218 -62
  121. package/src/schema-region.ts +46 -0
  122. package/src/types.ts +54 -0
  123. package/src/validate-cel-context.ts +36 -3
  124. package/src/validate-include-placement.ts +70 -0
  125. package/src/validate-step-inputs.ts +56 -1
  126. package/src/validate-throws-coverage.ts +16 -11
  127. package/src/validate-value-type-slots.ts +182 -0
  128. package/src/value-type-keyword.ts +135 -0
  129. package/src/yaml-source-edit.ts +106 -0
  130. package/dist/binary-slot.d.ts +0 -39
  131. package/dist/binary-slot.d.ts.map +0 -1
  132. package/dist/binary-slot.js +0 -58
  133. package/src/binary-slot.ts +0 -71
@@ -0,0 +1,260 @@
1
+ /** Rendering a migration plan back into the author's YAML.
2
+ *
3
+ * Edits are BYTE SPLICES computed from the parsed document's node ranges, not
4
+ * `Document.toString()`. That is `cli/nodejs/src/commands/upgrade.ts`'s
5
+ * precedent and it is load-bearing here for the same reason: re-serializing
6
+ * re-folds block scalars, drops quote-style hints and reflows long strings, so
7
+ * a one-key repair would arrive as a whole-file diff. Everything outside the
8
+ * spliced ranges is byte-identical to the input.
9
+ *
10
+ * The document is parsed with the same `defaultCustomTags()` every other
11
+ * parse site uses, so a tagged scalar is a node here exactly as it is in the
12
+ * loader's tree. */
13
+
14
+ import { isMap, isPair, isScalar, isSeq, type Document, type Node } from "yaml";
15
+ import { renderFixReplacement, type TextEdit } from "../yaml-source-edit.js";
16
+ import type { MigrationEffect } from "./patch.js";
17
+ import type { MigrationPath } from "./types.js";
18
+
19
+ export { applyTextEdits, type TextEdit } from "../yaml-source-edit.js";
20
+
21
+ function nodeAt(doc: Document, path: MigrationPath): unknown {
22
+ if (path.length === 0) return doc.contents;
23
+ return doc.getIn(path as (string | number)[], true);
24
+ }
25
+
26
+ function rangeOf(node: unknown): [number, number] | undefined {
27
+ const range = (node as { range?: unknown } | undefined)?.range;
28
+ if (!Array.isArray(range) || typeof range[0] !== "number" || typeof range[1] !== "number") {
29
+ return undefined;
30
+ }
31
+ return [range[0], range[1]];
32
+ }
33
+
34
+ /** The Pair whose key is `key` inside the map at `parent`. */
35
+ function pairAt(doc: Document, parent: MigrationPath, key: string): unknown {
36
+ const map = nodeAt(doc, parent);
37
+ if (!isMap(map)) return undefined;
38
+ return map.items.find(
39
+ (item) => isPair(item) && isScalar(item.key) && String(item.key.value) === key,
40
+ );
41
+ }
42
+
43
+ /** Re-quote `value` in the style the author used at `original`.
44
+ *
45
+ * A non-string is rendered as its YAML spelling and then handed to the shared
46
+ * rule, so a migration and a quick fix quote the same value identically — the
47
+ * rule itself is `renderFixReplacement` (`../yaml-source-edit.js`), which both
48
+ * this and `@telorun/ide-support` read rather than restate. */
49
+ function renderScalar(original: string, value: unknown): string | undefined {
50
+ if (value === null) return "null";
51
+ if (typeof value === "boolean" || typeof value === "number") return String(value);
52
+ if (typeof value !== "string") return undefined;
53
+ return renderFixReplacement(original, value);
54
+ }
55
+
56
+ /** Column of `offset` within its line, and the offset of that line's start. */
57
+ function lineGeometry(text: string, offset: number): { lineStart: number; indent: number } {
58
+ const lineStart = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
59
+ return { lineStart, indent: offset - lineStart };
60
+ }
61
+
62
+ /** One YAML edit per effect, or `undefined` when the effect cannot be written
63
+ * into this file — the same all-or-nothing rule the tree side has, so a
64
+ * partially applied patch never reaches disk. */
65
+ export function planTextEdits(
66
+ doc: Document,
67
+ text: string,
68
+ effects: readonly MigrationEffect[],
69
+ ): TextEdit[] | undefined {
70
+ if (effects.length === 0) return [];
71
+
72
+ // **The document is never mutated.** A patch's later effects address the node
73
+ // by its POST-rename path, which the file does not have — so every effect is
74
+ // resolved at the one location the patch matched, read off the first effect.
75
+ // The tree applier and this one therefore agree without either replaying the
76
+ // other's state.
77
+ const first = effects[0]!;
78
+ const docPath: MigrationPath =
79
+ first.kind === "rename-key" ? [...first.parent, first.from] : first.path;
80
+
81
+ // A removal supersedes every other edit at the location: `planPatch` refuses
82
+ // anything after a `remove-entry`, and the line it deletes contains them.
83
+ const removal = effects.find((e) => e.kind === "remove-entry");
84
+ if (removal) return removeEntryEdit(doc, text, docPath);
85
+
86
+ const edits: TextEdit[] = [];
87
+ let keyEdit: TextEdit | undefined;
88
+ // Value edits compose into ONE splice: a `set-tag` after a `set-value` must
89
+ // emit the tag in front of the NEW scalar, not re-read the old one.
90
+ let valueSpan: [number, number] | undefined;
91
+ let scalarText: string | undefined;
92
+ let tagText: string | undefined;
93
+
94
+ for (const effect of effects) {
95
+ switch (effect.kind) {
96
+ case "rename-key": {
97
+ const pair = pairAt(doc, docPath.slice(0, -1), String(docPath[docPath.length - 1]));
98
+ if (!isPair(pair) || !isScalar(pair.key)) return undefined;
99
+ const range = rangeOf(pair.key);
100
+ if (!range) return undefined;
101
+ const rendered = renderScalar(text.slice(range[0], range[1]), effect.to);
102
+ if (rendered === undefined) return undefined;
103
+ keyEdit = { start: range[0], end: range[1], newText: rendered };
104
+ break;
105
+ }
106
+ case "set-value": {
107
+ const range = rangeOf(nodeAt(doc, docPath));
108
+ if (!range) return undefined;
109
+ const rendered = renderScalar(text.slice(range[0], range[1]), effect.value);
110
+ if (rendered === undefined) return undefined;
111
+ valueSpan ??= range;
112
+ scalarText = rendered;
113
+ break;
114
+ }
115
+ case "set-tag": {
116
+ const node = nodeAt(doc, docPath);
117
+ if (!isScalar(node)) return undefined;
118
+ const range = rangeOf(node);
119
+ if (!range) return undefined;
120
+ // The tag sits outside the scalar's own span, so an existing one is
121
+ // replaced by rewriting from where it starts; a plain scalar just gains
122
+ // a prefix.
123
+ const existing = typeof node.tag === "string" ? node.tag : undefined;
124
+ const tagStart = existing ? text.lastIndexOf(existing, range[0]) : -1;
125
+ valueSpan = [tagStart >= 0 ? tagStart : range[0], range[1]];
126
+ scalarText ??= text.slice(range[0], range[1]);
127
+ tagText = `!${effect.tag}`;
128
+ break;
129
+ }
130
+ case "insert-item": {
131
+ const edit = insertItemEdit(doc, text, docPath, effect.index, effect.value);
132
+ if (!edit) return undefined;
133
+ edits.push(edit);
134
+ break;
135
+ }
136
+ case "remove-entry":
137
+ // Handled above.
138
+ break;
139
+ }
140
+ }
141
+
142
+ if (keyEdit) edits.push(keyEdit);
143
+ if (valueSpan && scalarText !== undefined) {
144
+ edits.push({
145
+ start: valueSpan[0],
146
+ end: valueSpan[1],
147
+ newText: tagText ? `${tagText} ${scalarText}` : scalarText,
148
+ });
149
+ }
150
+ return edits;
151
+ }
152
+
153
+ function insertItemEdit(
154
+ doc: Document,
155
+ text: string,
156
+ path: MigrationPath,
157
+ index: number,
158
+ value: unknown,
159
+ ): TextEdit | undefined {
160
+ const seq = nodeAt(doc, path);
161
+ // An empty sequence is written `[]` in flow style, which has no item line to
162
+ // extend — the one insert shape this cannot render.
163
+ if (!isSeq(seq) || seq.items.length === 0) return undefined;
164
+ const rendered = renderScalar("", value);
165
+ if (rendered === undefined) return undefined;
166
+
167
+ const anchorIndex = Math.min(index, seq.items.length - 1);
168
+ const anchor = rangeOf(seq.items[anchorIndex] as Node);
169
+ if (!anchor) return undefined;
170
+ const { lineStart, indent } = lineGeometry(text, anchor[0]);
171
+ // A block sequence's item starts two columns after its `- `; a flow sequence
172
+ // has no line of its own.
173
+ if (text.slice(lineStart, anchor[0]).trimStart() !== "- ") return undefined;
174
+ const line = `${" ".repeat(Math.max(0, indent - 2))}- ${rendered}\n`;
175
+
176
+ if (index >= seq.items.length) {
177
+ const last = rangeOf(seq.items[seq.items.length - 1] as Node);
178
+ if (!last) return undefined;
179
+ const lineEnd = text.indexOf("\n", last[1]);
180
+ const at = lineEnd < 0 ? text.length : lineEnd + 1;
181
+ return { start: at, end: at, newText: line };
182
+ }
183
+ return { start: lineStart, end: lineStart, newText: line };
184
+ }
185
+
186
+ function removeEntryEdit(
187
+ doc: Document,
188
+ text: string,
189
+ path: MigrationPath,
190
+ ): TextEdit[] | undefined {
191
+ const located = locateEntry(doc, path);
192
+ if (!located) return undefined;
193
+ const { lineStart } = lineGeometry(text, located.start);
194
+ const prefix = text.slice(lineStart, located.start);
195
+ const opensSequenceItem = /^-\s+$/.test(prefix.trimStart());
196
+
197
+ // The entry OWNS its line — indentation for a mapping entry, indentation plus
198
+ // `- ` for a sequence item — so removing the line is exact.
199
+ if (located.item ? opensSequenceItem : prefix.trim() === "") {
200
+ const lineEnd = text.indexOf("\n", located.end);
201
+ return [{ start: lineStart, end: lineEnd < 0 ? text.length : lineEnd + 1, newText: "" }];
202
+ }
203
+
204
+ // A mapping entry that OPENS a sequence item shares its line with the `- `,
205
+ // and is the overwhelmingly common shape of the one thing a migration removes
206
+ // today: a legacy ref slot is almost always an `anyOf` branch, written
207
+ // `- type: string` with the annotation beneath it. Deleting the line would
208
+ // take the dash with it and fold the item into its predecessor — so the entry
209
+ // is spliced out up to the FOLLOWING sibling's key instead, which slides onto
210
+ // the dash at the column it already occupies. Refusing here would have made
211
+ // the diagnostic's own advice ("run `telo migrate`") dead for the case it is
212
+ // most often given in.
213
+ if (!located.item && opensSequenceItem) {
214
+ const next = nextSiblingKeyStart(doc, path);
215
+ // Nothing to promote onto the dash: the entry is the item's only one, and
216
+ // removing it would leave `- ` with no value.
217
+ if (next === undefined) return undefined;
218
+ // Only whitespace may be swallowed. A comment or anything else between the
219
+ // two entries would be destroyed by the splice, so that stays a hand edit.
220
+ const between = text.slice(located.end, next);
221
+ if (!/^\s*$/.test(between) || !between.includes("\n")) return undefined;
222
+ return [{ start: located.start, end: next, newText: "" }];
223
+ }
224
+ return undefined;
225
+ }
226
+
227
+ /** Offset of the key of the entry FOLLOWING `path` in its own mapping, or
228
+ * `undefined` when `path` is the last entry (or anything is not a plain
229
+ * key-scalar pair). */
230
+ function nextSiblingKeyStart(doc: Document, path: MigrationPath): number | undefined {
231
+ const map = nodeAt(doc, path.slice(0, -1));
232
+ if (!isMap(map)) return undefined;
233
+ const key = String(path[path.length - 1]);
234
+ const at = map.items.findIndex(
235
+ (item) => isPair(item) && isScalar(item.key) && String(item.key.value) === key,
236
+ );
237
+ if (at < 0) return undefined;
238
+ const next = map.items[at + 1];
239
+ if (!isPair(next) || !isScalar(next.key)) return undefined;
240
+ return rangeOf(next.key)?.[0];
241
+ }
242
+
243
+ /** Span of a whole mapping entry (key through value) or sequence item. */
244
+ function locateEntry(
245
+ doc: Document,
246
+ path: MigrationPath,
247
+ ): { start: number; end: number; item: boolean } | undefined {
248
+ const last = path[path.length - 1];
249
+ if (typeof last === "string") {
250
+ const pair = pairAt(doc, path.slice(0, -1), last);
251
+ if (!isPair(pair) || !isScalar(pair.key)) return undefined;
252
+ const keyRange = rangeOf(pair.key);
253
+ const valueRange = rangeOf(pair.value) ?? keyRange;
254
+ if (!keyRange || !valueRange) return undefined;
255
+ return { start: keyRange[0], end: valueRange[1], item: false };
256
+ }
257
+ const range = rangeOf(nodeAt(doc, path));
258
+ if (!range) return undefined;
259
+ return { start: range[0], end: range[1], item: true };
260
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ defaultCustomTags,
3
+ defaultRegistry,
4
+ walkCelExpressions,
5
+ type TemplatingEngineRegistry,
6
+ } from "@telorun/templating";
7
+ import { PackageURL } from "packageurl-js";
8
+ import { parseAllDocuments } from "yaml";
9
+ import { selectorFromQualifiers, selectorKey, type ArtifactSelector } from "./artifact-selector.js";
10
+
11
+ /**
12
+ * One module-relative file a manifest names, and the artifact layer it belongs
13
+ * to.
14
+ *
15
+ * The single answer to "why is this file in the payload", replacing two
16
+ * derivations that happened to agree: publish used to re-parse the manifest with
17
+ * PURL knowledge hardcoded into the CLI, and any second vocabulary — a tag that
18
+ * embeds a file, say — would have had to be added there by hand. Here the
19
+ * knowledge sits with whoever owns the syntax: a controller candidate is read by
20
+ * this module, and a tagged value is read by the engine that owns its tag, via
21
+ * `TemplatingEngine.fileClaims`. Publish maps role to layer and recognises
22
+ * neither.
23
+ *
24
+ * Deliberately NOT hung off `analyze()`. That pass runs over a flattened,
25
+ * import-inclusive manifest set, so its claims would mix in imported libraries'
26
+ * files — whose paths are relative to *their* module and must never join this
27
+ * artifact — and it would make packaging, today derivable offline from manifest
28
+ * text, a product of resolving the whole import graph. This is per-module by
29
+ * construction and needs nothing but the text.
30
+ *
31
+ * Browser-safe, like the rest of the analyzer: parsing and string work only, no
32
+ * filesystem. Whether a claimed file EXISTS is a separate question, asked by the
33
+ * Node-side caller that has a directory to look in.
34
+ */
35
+ interface ClaimBase {
36
+ /** Module-root-relative POSIX path — relative to the directory holding
37
+ * `telo.yaml`, never to the file the claim was written in. Publish inlines
38
+ * every `include:` partial into the published `telo.yaml`, so a
39
+ * per-file-relative path would change meaning in the artifact. */
40
+ readonly path: string;
41
+ /** Where the claim came from, for diagnostics: the PURL, or `!<tag>` and the
42
+ * path of the value that carried it. */
43
+ readonly origin: string;
44
+ }
45
+
46
+ /**
47
+ * A **discriminated union**, not one shape with optional fields: a controller
48
+ * layer is one per selector and carries sibling patterns, and an assets layer is
49
+ * neither. Optional fields on a single shape put the consumer one `!` away from
50
+ * a crash inside `selectorKey` with no useful message, and let a producer emit a
51
+ * controller claim with no selector that nothing would reject.
52
+ */
53
+ export type ModuleFileClaim =
54
+ | (ClaimBase & {
55
+ readonly role: "controller";
56
+ readonly selector: ArtifactSelector;
57
+ /** Extra payload patterns that belong in the same layer as this claim —
58
+ * `.gitignore`-style globs over the selected files, matched by the
59
+ * caller, which is the side that knows what was selected. */
60
+ readonly siblings: readonly string[];
61
+ })
62
+ | (ClaimBase & { readonly role: "assets" });
63
+
64
+ /** `pkg:telo/local/<format>?path=…` — the bundled-controller delivery mode.
65
+ * Anything else (`pkg:npm`, `pkg:cargo`) fetches from its own ecosystem and
66
+ * contributes no layer. */
67
+ const BUNDLED_TYPE = "telo";
68
+ const BUNDLED_NAMESPACE = "local";
69
+
70
+ /** Qualifier naming extra files that belong in a controller's layer — what an
71
+ * entry point loads but the manifest cannot otherwise see (a `.wasm` beside its
72
+ * glue, a native library opened at runtime). */
73
+ const SIBLINGS_QUALIFIER = "siblings";
74
+
75
+ /** Normalize a `path=` / sibling value to the manifest-relative POSIX form the
76
+ * file selector returns, so membership is a string comparison. */
77
+ function normalizeRelative(value: string): string {
78
+ return value.replace(/^\.\//, "").replace(/\\/g, "/");
79
+ }
80
+
81
+ /** Bundled-controller claims from one document's `controllers:` list. */
82
+ function controllerClaims(json: unknown): ModuleFileClaim[] {
83
+ const candidates = (json as { controllers?: unknown } | null)?.controllers;
84
+ if (!Array.isArray(candidates)) return [];
85
+ const claims: ModuleFileClaim[] = [];
86
+ for (const candidate of candidates) {
87
+ if (typeof candidate !== "string") continue;
88
+ let parsed: PackageURL;
89
+ try {
90
+ parsed = PackageURL.fromString(candidate);
91
+ } catch {
92
+ // Not a parseable PURL — claim collection is not the place to reject it;
93
+ // the analyzer's own validation and the controller loader both report it
94
+ // with better context.
95
+ continue;
96
+ }
97
+ if (parsed.type !== BUNDLED_TYPE || parsed.namespace !== BUNDLED_NAMESPACE) continue;
98
+ const entry = parsed.qualifiers?.path;
99
+ if (typeof entry !== "string" || entry === "") continue;
100
+ claims.push({
101
+ role: "controller",
102
+ path: normalizeRelative(entry),
103
+ selector: selectorFromQualifiers(parsed.name, parsed.qualifiers, `controller "${candidate}"`),
104
+ siblings: String(parsed.qualifiers?.[SIBLINGS_QUALIFIER] ?? "")
105
+ .split(",")
106
+ .map((p) => p.trim())
107
+ .filter((p) => p !== ""),
108
+ origin: candidate,
109
+ });
110
+ }
111
+ return claims;
112
+ }
113
+
114
+ /** Claims contributed by tagged values, asked of the engine that owns each tag.
115
+ * The walk reaches every tagged scalar in the document, so an engine that
116
+ * embeds files is discovered wherever its tag was written.
117
+ *
118
+ * The layer role is assigned HERE, not by the engine: an engine reports what it
119
+ * embeds, and which layer that belongs in is this module's vocabulary. A file a
120
+ * tag embeds is read only when the resource holding it is created, so `assets`
121
+ * — the lazily-fetched layer — is what it is. */
122
+ function taggedClaims(json: unknown, registry: TemplatingEngineRegistry): ModuleFileClaim[] {
123
+ const claims: ModuleFileClaim[] = [];
124
+ walkCelExpressions(json, "", (source, path, engineName) => {
125
+ const engine = registry.get(engineName);
126
+ for (const claim of engine?.fileClaims?.(source) ?? []) {
127
+ claims.push({ role: "assets", path: claim.path, origin: `!${engineName} at '${path}'` });
128
+ }
129
+ });
130
+ return claims;
131
+ }
132
+
133
+ /** Identity of a claim for de-duplication: the same file claimed twice by two
134
+ * resources is one file in one layer. Role and selector are part of it because
135
+ * a file two controller candidates both claim is genuinely copied into each of
136
+ * their layers — dropping one would leave a platform's layer short a file it
137
+ * declared it needs. */
138
+ function claimKey(claim: ModuleFileClaim): string {
139
+ const selector = claim.role === "controller" ? selectorKey(claim.selector) : "";
140
+ return `${claim.role}\0${selector}\0${claim.path}`;
141
+ }
142
+
143
+ /**
144
+ * Every module-relative file the manifest names, from every syntax that can name
145
+ * one.
146
+ *
147
+ * `manifestText` is one module's `telo.yaml`. Publish passes the text it is
148
+ * about to ship — i.e. after `include:` partials have been inlined — but the
149
+ * answer does not depend on that: claims are root-relative, so collecting them
150
+ * before or after inlining gives the same set.
151
+ */
152
+ export function collectModuleFileClaims(
153
+ manifestText: string,
154
+ registry: TemplatingEngineRegistry = defaultRegistry(),
155
+ ): ModuleFileClaim[] {
156
+ const seen = new Set<string>();
157
+ const claims: ModuleFileClaim[] = [];
158
+ for (const doc of parseAllDocuments(manifestText, { customTags: defaultCustomTags() })) {
159
+ const json = doc.toJSON() as unknown;
160
+ for (const claim of [...controllerClaims(json), ...taggedClaims(json, registry)]) {
161
+ const key = claimKey(claim);
162
+ if (seen.has(key)) continue;
163
+ seen.add(key);
164
+ claims.push(claim);
165
+ }
166
+ }
167
+ return claims;
168
+ }
@@ -4,6 +4,8 @@ import { defaultCustomTags } from "@telorun/templating";
4
4
  import { parseAllDocuments } from "yaml";
5
5
  import { buildCelEnvironment } from "./cel-environment.js";
6
6
  import type { LoadedFile, ParseError } from "./loaded-types.js";
7
+ import { migrateManifests, NO_MIGRATIONS } from "./migrations/driver.js";
8
+ import type { MigrationEntry } from "./migrations/types.js";
7
9
  import { buildDocumentPositions } from "./position-metadata.js";
8
10
  import { precompileDoc } from "./precompile.js";
9
11
  import { documentToAst } from "./yaml-ast.js";
@@ -14,6 +16,13 @@ export interface ParseOptions {
14
16
  compile?: boolean;
15
17
  /** CEL environment for precompile. Defaults to `buildCelEnvironment()`. */
16
18
  celEnv?: Environment;
19
+ /** When true, the migration phase runs over the parsed documents — legacy
20
+ * spellings rewritten to the current ones before anything else reads the
21
+ * tree. Off by default so a round-trip consumer (the editor) keeps the
22
+ * author's vocabulary; see `LoadOptions.migrate`. */
23
+ migrate?: boolean;
24
+ /** Migration set. Defaults to the analyzer's own `CORE_MIGRATIONS`. */
25
+ migrations?: readonly MigrationEntry[];
17
26
  }
18
27
 
19
28
  /** Append an actionable hint to raw yaml-parser messages that are otherwise
@@ -64,26 +73,35 @@ export function parseLoadedFile(
64
73
  }
65
74
  });
66
75
 
67
- const manifests: Array<ResourceManifest | null> = [];
68
- let env: Environment | undefined;
69
- for (const doc of documents) {
76
+ const manifests: Array<ResourceManifest | null> = documents.map((doc) => {
70
77
  const raw = doc.toJSON();
71
- if (raw === null || raw === undefined) {
72
- manifests.push(null);
73
- continue;
74
- }
75
- if (options?.compile) {
78
+ return raw === null || raw === undefined ? null : (raw as ResourceManifest);
79
+ });
80
+
81
+ // The migration phase, immediately after parse. It runs BEFORE precompile so
82
+ // matchers see the values the author wrote rather than `CompiledValue`
83
+ // wrappers, and — through the loader — before `desugarLoadedFile`, so a rule
84
+ // only ever matches author-written nodes: a synthetic Telo.Import manifest
85
+ // has no YAML document to edit, records a path the file never had, and
86
+ // carries its `variables` / `secrets` by reference from the module doc, so a
87
+ // match inside one would apply twice.
88
+ const migrations = options?.migrate
89
+ ? migrateManifests({ source, manifests, entries: options.migrations })
90
+ : NO_MIGRATIONS;
91
+
92
+ let env: Environment | undefined;
93
+ if (options?.compile) {
94
+ for (let i = 0; i < manifests.length; i++) {
95
+ const raw = manifests[i];
96
+ if (raw === null) continue;
76
97
  env ??= options.celEnv ?? buildCelEnvironment();
77
98
  try {
78
- const compiled = precompileDoc(raw, env);
79
- manifests.push(compiled as ResourceManifest);
99
+ manifests[i] = precompileDoc(raw, env) as ResourceManifest;
80
100
  } catch (error) {
81
101
  throw new Error(
82
102
  `Failed to compile manifest in ${source}: ${error instanceof Error ? error.message : String(error)}`,
83
103
  );
84
104
  }
85
- } else {
86
- manifests.push(raw as ResourceManifest);
87
105
  }
88
106
  }
89
107
 
@@ -96,5 +114,6 @@ export function parseLoadedFile(
96
114
  manifests,
97
115
  positions,
98
116
  parseErrors,
117
+ migrations,
99
118
  };
100
119
  }
@@ -6,30 +6,57 @@ import type { AliasResolver } from "./alias-resolver.js";
6
6
  const SCHEMA_FIELDS = ["schema", "inputType", "outputType"];
7
7
 
8
8
  /**
9
- * Rewrites import-scoped schema references in place. A `$ref` of the form
10
- * `telo://<authority>/<typeName>` names a `Type.JsonSchema` (or any `Telo.Type`)
11
- * reached through an import: `telo://Self/<type>` for the declaring module's own
12
- * type, `telo://<Alias>/<type>` for an imported module's. Each authority is
13
- * resolved to the owning module's name and the ref is rewritten to the canonical
14
- * `telo://<module>/<type>` the type registered its schema under.
9
+ * Rewrites schema references to their canonical, module-scoped form, in place.
15
10
  *
16
- * The version lives on the `imports:` entry, never the URI only the pinned
17
- * version is loaded, so the canonical id is version-free.
11
+ * A named shape is written with the reference tag `!ref File` for the declaring
12
+ * module's own, `!ref <Alias>.<File>` for one an imported library exports — which
13
+ * is Telo's ONE reference grammar, and `use: schema` has been in the `x-telo-ref`
14
+ * vocabulary for exactly this relation all along (*names a shape; no runtime
15
+ * instance, no edge*). Phase 2.5 has already turned the tag into `{kind, name,
16
+ * alias?}`; this pass turns that into `{ $ref: "telo:<module>/<type>" }`, the id
17
+ * the type registered its schema under.
18
18
  *
19
- * Already-canonical refs (authority is a real module name, not an alias) and
20
- * fragment-bearing built-ins (`telo://manifest#/$defs/ResourceRef`) are left
21
- * untouched: the former because the authority resolves to nothing, the latter
22
- * because they don't match the `authority/type` grammar.
19
+ * WHY NORMALIZE RATHER THAN RESOLVE. Substituting the shape itself would inline
20
+ * it, which changes schema identity — the compiled-validator cache is keyed on it
21
+ * and would make a self-recursive shape inexpressible. Keeping a REFERENCE is
22
+ * what every validator wants, and it is not an AJV concern: a Rust validator
23
+ * wants a registered id just as much. So the tag is the authoring surface and the
24
+ * canonical `$ref` is the internal form, exactly as `resolveRefSentinels` and
25
+ * `resolveSchemaRefKinds` already split authoring sugar from canonical form.
26
+ *
27
+ * ALIAS SCOPE IS WHAT THE CANONICAL FORM CARRIES. The authority is resolved in
28
+ * the DECLARING module's scope, so the id names the owning module and two
29
+ * libraries declaring a shape of the same name stay distinct. A downstream
30
+ * resolver reads the module off the id instead of matching a bare name, which is
31
+ * how an alias used to get silently dropped.
32
+ *
33
+ * The legacy authoring spelling `$ref: "telo://<authority>/<type>"` is resolved
34
+ * the same way, since a published artifact carries it. The version lives on the
35
+ * `imports:` entry, never the URI — only the pinned version is loaded, so the
36
+ * canonical id is version-free. Already-canonical refs and fragment-bearing
37
+ * built-ins (`telo://manifest#/$defs/ResourceRef`) are left untouched: the former
38
+ * because the authority resolves to nothing, the latter because they do not match
39
+ * the `authority/type` grammar.
23
40
  */
24
41
  export function resolveSchemaTypeRefs(
25
42
  resources: ResourceManifest[],
26
43
  aliases?: AliasResolver,
27
44
  aliasesByModule?: Map<string, AliasResolver>,
28
45
  ): void {
29
- const walk = (value: unknown, resolveAuthority: (authority: string) => string | undefined): void => {
46
+ const walk = (
47
+ value: unknown,
48
+ resolveAuthority: (authority: string) => string | undefined,
49
+ ownModule: string | undefined,
50
+ // The type field's OWN value is a declared reference slot (`x-telo-ref:
51
+ // Telo.Type`), with its own reference validation and its own editor picker.
52
+ // Only what sits INSIDE it is schema, and only schema is this pass's to
53
+ // canonicalize — rewriting the slot itself would hand the reference checker
54
+ // a shape it is right to reject.
55
+ isSlotRoot: boolean,
56
+ ): void => {
30
57
  if (value === null || typeof value !== "object") return;
31
58
  if (Array.isArray(value)) {
32
- for (const item of value) walk(item, resolveAuthority);
59
+ for (const item of value) walk(item, resolveAuthority, ownModule, false);
33
60
  return;
34
61
  }
35
62
  const obj = value as Record<string, unknown>;
@@ -38,7 +65,33 @@ export function resolveSchemaTypeRefs(
38
65
  const module = resolveAuthority(parsed.authority);
39
66
  if (module) obj.$ref = canonicalTypeSchemaId(module, parsed.typeName);
40
67
  }
41
- for (const key of Object.keys(obj)) walk(obj[key], resolveAuthority);
68
+ // A reference the tag left behind. Rewritten in place rather than replaced,
69
+ // so a node that carried siblings (a `title`, a `description`) keeps them —
70
+ // a `$ref` beside other keywords is ordinary JSON Schema.
71
+ const named = resolvedRefInSchema(obj);
72
+ if (named) {
73
+ const module = named.alias === undefined ? ownModule : resolveAuthority(named.alias);
74
+ // An unresolvable alias is left exactly as written: `validateReferences`
75
+ // is what reports a reference that names nothing, and guessing a module
76
+ // here would turn a typo into a `$ref` that resolves to the wrong shape.
77
+ if (module) {
78
+ const canonical = canonicalTypeSchemaId(module, named.name);
79
+ if (isSlotRoot) {
80
+ // At the slot root the reference STAYS a reference — the canonical id
81
+ // is stamped beside it. That is what makes the resolver alias-aware
82
+ // here: it reads the module off the id instead of matching a bare
83
+ // `metadata.name` across a flattened list, where two libraries
84
+ // declaring a shape of the same name collide silently.
85
+ obj.$ref = canonical;
86
+ } else {
87
+ delete obj.kind;
88
+ delete obj.name;
89
+ delete obj.alias;
90
+ obj.$ref = canonical;
91
+ }
92
+ }
93
+ }
94
+ for (const key of Object.keys(obj)) walk(obj[key], resolveAuthority, ownModule, false);
42
95
  };
43
96
 
44
97
  for (const r of resources) {
@@ -47,7 +100,31 @@ export function resolveSchemaTypeRefs(
47
100
  const resolveAuthority = (authority: string): string | undefined =>
48
101
  authority === "Self" ? ownModule : resolver?.moduleForAlias(authority);
49
102
  for (const field of SCHEMA_FIELDS) {
50
- walk((r as Record<string, unknown>)[field], resolveAuthority);
103
+ walk((r as Record<string, unknown>)[field], resolveAuthority, ownModule, true);
51
104
  }
52
105
  }
53
106
  }
107
+
108
+ /**
109
+ * A resolved `!ref` sitting where a schema belongs, or null.
110
+ *
111
+ * Recognised structurally: Phase 2.5 writes `{kind, name, alias?}` and nothing
112
+ * else, and a node carrying JSON Schema keywords is a schema that happens to be
113
+ * beside one rather than a reference. Being conservative here is the safe
114
+ * direction — a node this declines to rewrite stays a reference for
115
+ * `validateReferences` to judge, while one it rewrote wrongly would silently
116
+ * become a different shape.
117
+ */
118
+ function resolvedRefInSchema(
119
+ obj: Record<string, unknown>,
120
+ ): { name: string; alias?: string } | null {
121
+ if (typeof obj.kind !== "string" || typeof obj.name !== "string") return null;
122
+ for (const key of Object.keys(obj)) {
123
+ // `$ref` is allowed so the pass is idempotent at a slot root, where the id
124
+ // is stamped BESIDE the reference rather than replacing it.
125
+ if (key !== "kind" && key !== "name" && key !== "alias" && key !== "$ref") return null;
126
+ }
127
+ return typeof obj.alias === "string"
128
+ ? { name: obj.name, alias: obj.alias }
129
+ : { name: obj.name };
130
+ }