@telorun/analyzer 0.57.0 → 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 (116) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +91 -9
  3. package/dist/cel-environment.d.ts.map +1 -1
  4. package/dist/cel-environment.js +6 -3
  5. package/dist/definition-registry.d.ts +5 -0
  6. package/dist/definition-registry.d.ts.map +1 -1
  7. package/dist/definition-registry.js +9 -0
  8. package/dist/index.d.ts +10 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +6 -2
  11. package/dist/invocation-contract.d.ts +21 -13
  12. package/dist/invocation-contract.d.ts.map +1 -1
  13. package/dist/invocation-contract.js +43 -46
  14. package/dist/loaded-types.d.ts +13 -0
  15. package/dist/loaded-types.d.ts.map +1 -1
  16. package/dist/manifest-loader.d.ts +8 -1
  17. package/dist/manifest-loader.d.ts.map +1 -1
  18. package/dist/manifest-loader.js +33 -6
  19. package/dist/migrations/driver.d.ts +69 -0
  20. package/dist/migrations/driver.d.ts.map +1 -0
  21. package/dist/migrations/driver.js +200 -0
  22. package/dist/migrations/entries/index.d.ts +3 -0
  23. package/dist/migrations/entries/index.d.ts.map +1 -0
  24. package/dist/migrations/entries/index.js +9 -0
  25. package/dist/migrations/entries/normalize-value-types.json +48 -0
  26. package/dist/migrations/entries/ref-slot-scalar-type.json +20 -0
  27. package/dist/migrations/entry-data.d.ts +25 -0
  28. package/dist/migrations/entry-data.d.ts.map +1 -0
  29. package/dist/migrations/entry-data.js +171 -0
  30. package/dist/migrations/index.d.ts +22 -0
  31. package/dist/migrations/index.d.ts.map +1 -0
  32. package/dist/migrations/index.js +19 -0
  33. package/dist/migrations/match.d.ts +162 -0
  34. package/dist/migrations/match.d.ts.map +1 -0
  35. package/dist/migrations/match.js +295 -0
  36. package/dist/migrations/patch.d.ts +68 -0
  37. package/dist/migrations/patch.d.ts.map +1 -0
  38. package/dist/migrations/patch.js +236 -0
  39. package/dist/migrations/provenance.d.ts +25 -0
  40. package/dist/migrations/provenance.d.ts.map +1 -0
  41. package/dist/migrations/provenance.js +118 -0
  42. package/dist/migrations/registry.d.ts +35 -0
  43. package/dist/migrations/registry.d.ts.map +1 -0
  44. package/dist/migrations/registry.js +35 -0
  45. package/dist/migrations/report.d.ts +28 -0
  46. package/dist/migrations/report.d.ts.map +1 -0
  47. package/dist/migrations/report.js +144 -0
  48. package/dist/migrations/types.d.ts +115 -0
  49. package/dist/migrations/types.d.ts.map +1 -0
  50. package/dist/migrations/types.js +24 -0
  51. package/dist/migrations/yaml-edit.d.ts +21 -0
  52. package/dist/migrations/yaml-edit.d.ts.map +1 -0
  53. package/dist/migrations/yaml-edit.js +245 -0
  54. package/dist/parse-loaded-file.d.ts +8 -0
  55. package/dist/parse-loaded-file.d.ts.map +1 -1
  56. package/dist/parse-loaded-file.js +22 -13
  57. package/dist/resolve-schema-type-refs.d.ts +29 -12
  58. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  59. package/dist/resolve-schema-type-refs.js +89 -16
  60. package/dist/schema-compat.d.ts +35 -9
  61. package/dist/schema-compat.d.ts.map +1 -1
  62. package/dist/schema-compat.js +180 -64
  63. package/dist/schema-region.d.ts +33 -0
  64. package/dist/schema-region.d.ts.map +1 -0
  65. package/dist/schema-region.js +45 -0
  66. package/dist/types.d.ts +17 -0
  67. package/dist/types.d.ts.map +1 -1
  68. package/dist/validate-cel-context.d.ts.map +1 -1
  69. package/dist/validate-cel-context.js +29 -3
  70. package/dist/validate-step-inputs.d.ts +8 -1
  71. package/dist/validate-step-inputs.d.ts.map +1 -1
  72. package/dist/validate-step-inputs.js +47 -2
  73. package/dist/validate-value-type-slots.d.ts +43 -0
  74. package/dist/validate-value-type-slots.d.ts.map +1 -0
  75. package/dist/validate-value-type-slots.js +142 -0
  76. package/dist/value-type-keyword.d.ts +60 -0
  77. package/dist/value-type-keyword.d.ts.map +1 -0
  78. package/dist/value-type-keyword.js +119 -0
  79. package/dist/yaml-source-edit.d.ts +56 -0
  80. package/dist/yaml-source-edit.d.ts.map +1 -0
  81. package/dist/yaml-source-edit.js +93 -0
  82. package/package.json +3 -3
  83. package/src/analyzer.ts +99 -6
  84. package/src/cel-environment.ts +6 -3
  85. package/src/definition-registry.ts +10 -0
  86. package/src/index.ts +26 -2
  87. package/src/invocation-contract.ts +43 -48
  88. package/src/loaded-types.ts +13 -0
  89. package/src/manifest-loader.ts +37 -6
  90. package/src/migrations/driver.ts +262 -0
  91. package/src/migrations/entries/index.ts +10 -0
  92. package/src/migrations/entries/normalize-value-types.json +48 -0
  93. package/src/migrations/entries/ref-slot-scalar-type.json +20 -0
  94. package/src/migrations/entry-data.ts +207 -0
  95. package/src/migrations/index.ts +22 -0
  96. package/src/migrations/match.ts +382 -0
  97. package/src/migrations/patch.ts +265 -0
  98. package/src/migrations/provenance.ts +167 -0
  99. package/src/migrations/registry.ts +40 -0
  100. package/src/migrations/report.ts +169 -0
  101. package/src/migrations/types.ts +144 -0
  102. package/src/migrations/yaml-edit.ts +260 -0
  103. package/src/parse-loaded-file.ts +31 -12
  104. package/src/resolve-schema-type-refs.ts +93 -16
  105. package/src/schema-compat.ts +212 -74
  106. package/src/schema-region.ts +46 -0
  107. package/src/types.ts +17 -0
  108. package/src/validate-cel-context.ts +36 -3
  109. package/src/validate-step-inputs.ts +56 -1
  110. package/src/validate-value-type-slots.ts +182 -0
  111. package/src/value-type-keyword.ts +135 -0
  112. package/src/yaml-source-edit.ts +106 -0
  113. package/dist/binary-slot.d.ts +0 -39
  114. package/dist/binary-slot.d.ts.map +0 -1
  115. package/dist/binary-slot.js +0 -58
  116. package/src/binary-slot.ts +0 -71
@@ -0,0 +1,295 @@
1
+ /** The selector half of a migration: what a rule MATCHES, as data.
2
+ *
3
+ * A patch addresses a known location; a migration has to find *every*
4
+ * occurrence of a legacy spelling, so selection is the half a plain patch
5
+ * format has none of. It is declarative for the same reason the operations
6
+ * are: an entry travels to a Rust and a Go kernel, and a predicate expressed
7
+ * in one language would mean one artifact is read two ways — invisibly, since
8
+ * a migration that succeeds is silent.
9
+ *
10
+ * **Containment is POSITIVE and required.** A rule states which document kinds
11
+ * it may touch (`inKind`) and which region of those documents it may reach
12
+ * into (`under`); nothing outside is reachable. The alternative — walk
13
+ * everything and subtract — cannot be made sound, because the set to subtract
14
+ * is unbounded: a `Run.Value` value, an `Assert.Equals` expected, any kind
15
+ * whose config carries a user JSON blob can hold something shaped like the
16
+ * node a rule looks for, and forgetting one corrupts a manifest with no
17
+ * diagnostic. A denylist also cannot express the guarantee the module surface
18
+ * is promised to carry — *a dependency can rename its own field and provably
19
+ * nothing else* — which is a statement about what a rule may reach, so it has
20
+ * to be said positively. `notUnder` remains for subtracting inside a region a
21
+ * rule legitimately reaches, which is a narrowing, not the containment itself.
22
+ *
23
+ * **`under` is ANCHORED at the document root**, not a set of key names to look
24
+ * for anywhere on the path. Anchoring is what makes the containment claim
25
+ * true: a `Telo.Definition`'s `resources:` template body carries other kinds'
26
+ * configuration, and any of it may hold a key spelled `schema` over data that
27
+ * merely looks like a schema — so "some segment of the path is `schema`" would
28
+ * reach the very user JSON blob the positive form exists to keep out, and
29
+ * would delete from it silently. Anchored, `under` names top-level document
30
+ * keys and a region is a genuine subtree.
31
+ *
32
+ * **`inSchema` is the one region a kind list cannot name.** An annotation
33
+ * keyword occurs in author-written JSON Schema, and schema fragments are not
34
+ * confined to kind documents: an inline `inputType:` / `outputType:` on ANY kind
35
+ * that declares one, an API route's `request.schema.body`, a `Telo.JsonSchema`'s
36
+ * `schema`. That set of kinds is open — any kind may declare a schema-valued
37
+ * field — and enumerating the standard library's would put resource-kind
38
+ * knowledge into the analyzer, against the topology-driven constraint. So a rule
39
+ * may instead state that it reaches only inside a SCHEMA REGION, named by the
40
+ * KERNEL's own schema-valued keys, which no kind owns.
41
+ *
42
+ * With `inSchema`, and only with it, `inKind` / `under` may be `["*"]` — and
43
+ * only for a rule keyed on a reserved `x-telo-*` annotation. That pairing IS the
44
+ * containment: the region gate bounds where the walk may go, and the reserved-key
45
+ * rule bounds what it may touch, since an `x-telo-*` key is Telo vocabulary
46
+ * wherever it appears and cannot mean something else in someone's config. Both
47
+ * halves are refused at entry-read time, so a module-shipped entry can no more
48
+ * spell `"*"` than it can name another module's kind.
49
+ *
50
+ * The residue is stated rather than claimed away: a manifest that asserts ABOUT
51
+ * a schema — a schema literal under a key spelled `schema` inside an assertion's
52
+ * expected value — is reachable, and would be rewritten into its own synonym.
53
+ * That cannot be closed in a data-only matcher without naming kinds. It is
54
+ * accepted because the sites the wildcards reach are exactly the ones no
55
+ * enumeration covers, and the alternative leaves an author reading a deprecation
56
+ * `telo migrate` refuses to act on.
57
+ *
58
+ * The vocabulary is closed, which is what makes it a trust boundary once
59
+ * module-shipped entries are aggregated beside core ones. An unrecognized key
60
+ * is refused rather than ignored — a selector that silently matches wider than
61
+ * it reads is the one failure this cannot tolerate. */
62
+ import { isInSchemaRegion } from "../schema-region.js";
63
+ /** The wildcard `inKind` / `under` value. Legal only alongside `inSchema`, and
64
+ * only for a rule keyed on an `x-telo-*` annotation. */
65
+ export const MATCH_ANY = "*";
66
+ export const MATCH_KEYS = [
67
+ "key",
68
+ "inKind",
69
+ "under",
70
+ "value",
71
+ "valueOneOf",
72
+ "withSibling",
73
+ "notUnder",
74
+ "inSchema",
75
+ ];
76
+ function isPlainObject(value) {
77
+ return typeof value === "object" && value !== null && !Array.isArray(value);
78
+ }
79
+ /** Structural equality over JSON values — what `value` / `valueOneOf` compare
80
+ * with, so a match on `true` never also matches `"true"`.
81
+ *
82
+ * Exported because the PATCHER asks the same question in the other direction:
83
+ * "is the value already what this would write". Both answers have to come from
84
+ * one rule, or a rule could match a spelling the patch then declares current. */
85
+ export function deepEquals(a, b) {
86
+ if (a === b)
87
+ return true;
88
+ if (Array.isArray(a) || Array.isArray(b)) {
89
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
90
+ return false;
91
+ return a.every((item, i) => deepEquals(item, b[i]));
92
+ }
93
+ if (isPlainObject(a) && isPlainObject(b)) {
94
+ const aKeys = Object.keys(a);
95
+ if (aKeys.length !== Object.keys(b).length)
96
+ return false;
97
+ return aKeys.every((k) => Object.hasOwn(b, k) && deepEquals(a[k], b[k]));
98
+ }
99
+ return false;
100
+ }
101
+ function requireStringList(describe, raw, key) {
102
+ const value = raw[key];
103
+ if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== "string")) {
104
+ throw new Error(`${describe}: 'match.${key}' must be a non-empty sequence of strings`);
105
+ }
106
+ return value;
107
+ }
108
+ /** Read a rule's `match` block, refusing anything the vocabulary does not
109
+ * define. `describe` names the entry and rule so a failure says which. */
110
+ export function readMigrationMatch(describe, raw) {
111
+ if (!isPlainObject(raw))
112
+ throw new Error(`${describe}: 'match' must be a mapping`);
113
+ for (const key of Object.keys(raw)) {
114
+ if (!MATCH_KEYS.includes(key)) {
115
+ throw new Error(`${describe}: 'match' has no key '${key}'. Known keys: ${MATCH_KEYS.join(", ")}.`);
116
+ }
117
+ }
118
+ if (typeof raw.key !== "string" || raw.key.length === 0) {
119
+ throw new Error(`${describe}: 'match.key' must be a non-empty string`);
120
+ }
121
+ if (Object.hasOwn(raw, "value") && Object.hasOwn(raw, "valueOneOf")) {
122
+ throw new Error(`${describe}: 'match' takes at most one of 'value' or 'valueOneOf'`);
123
+ }
124
+ if (Object.hasOwn(raw, "valueOneOf") && !Array.isArray(raw.valueOneOf)) {
125
+ throw new Error(`${describe}: 'match.valueOneOf' must be a sequence`);
126
+ }
127
+ if (Object.hasOwn(raw, "withSibling") && typeof raw.withSibling !== "string") {
128
+ throw new Error(`${describe}: 'match.withSibling' must be a string`);
129
+ }
130
+ if (Object.hasOwn(raw, "notUnder") &&
131
+ (!Array.isArray(raw.notUnder) || raw.notUnder.some((k) => typeof k !== "string"))) {
132
+ throw new Error(`${describe}: 'match.notUnder' must be a sequence of strings`);
133
+ }
134
+ if (Object.hasOwn(raw, "inSchema") && typeof raw.inSchema !== "boolean") {
135
+ throw new Error(`${describe}: 'match.inSchema' must be a boolean`);
136
+ }
137
+ // The two conditions that make a wildcard safe, refused here rather than
138
+ // narrowed later: a rule the reader accepts is a rule the driver will run, and
139
+ // a selector that matches wider than it reads is exactly what this vocabulary
140
+ // exists to prevent. A module-shipped entry therefore cannot spell `"*"` at
141
+ // all, because it cannot satisfy both halves for a keyword it does not own.
142
+ const wildcards = [
143
+ ...(Array.isArray(raw.inKind) ? raw.inKind : []),
144
+ ...(Array.isArray(raw.under) ? raw.under : []),
145
+ ].filter((value) => value === MATCH_ANY);
146
+ if (wildcards.length > 0) {
147
+ if (raw.inSchema !== true) {
148
+ throw new Error(`${describe}: 'match.inKind' / 'match.under' may only be '${MATCH_ANY}' together with ` +
149
+ `'inSchema: true' — the schema region is what bounds a walk the wildcard unbounds`);
150
+ }
151
+ if (!raw.key.startsWith("x-telo-")) {
152
+ throw new Error(`${describe}: '${MATCH_ANY}' is legal only for a rule keyed on an 'x-telo-*' annotation, ` +
153
+ `which is Telo vocabulary wherever it appears — '${raw.key}' could mean something else ` +
154
+ `inside a resource's own configuration`);
155
+ }
156
+ }
157
+ const match = {
158
+ key: raw.key,
159
+ inKind: requireStringList(describe, raw, "inKind"),
160
+ under: requireStringList(describe, raw, "under"),
161
+ };
162
+ if (Object.hasOwn(raw, "value"))
163
+ match.value = raw.value;
164
+ if (Object.hasOwn(raw, "valueOneOf"))
165
+ match.valueOneOf = raw.valueOneOf;
166
+ if (Object.hasOwn(raw, "withSibling"))
167
+ match.withSibling = raw.withSibling;
168
+ if (Object.hasOwn(raw, "notUnder"))
169
+ match.notUnder = raw.notUnder;
170
+ if (raw.inSchema === true)
171
+ match.inSchema = true;
172
+ return match;
173
+ }
174
+ /**
175
+ * Index `document`'s candidate sites for `keys`, descending only into the
176
+ * top-level regions named by `roots`.
177
+ *
178
+ * The caller has already gated on `inKind` — see `selectMatches`, which repeats
179
+ * the check because it holds the individual rule.
180
+ */
181
+ export function buildMatchIndex(document, keys, roots) {
182
+ const index = new Map();
183
+ if (keys.size === 0 || roots.size === 0 || !isPlainObject(document))
184
+ return index;
185
+ // A mutable stack, materialized into an array only when a site is recorded.
186
+ const stack = [];
187
+ const record = (key, value, parent) => {
188
+ const bucket = index.get(key) ?? [];
189
+ bucket.push({ path: [...stack], value, parent });
190
+ index.set(key, bucket);
191
+ };
192
+ const walk = (node) => {
193
+ if (Array.isArray(node)) {
194
+ for (let i = 0; i < node.length; i++) {
195
+ stack.push(i);
196
+ walk(node[i]);
197
+ stack.pop();
198
+ }
199
+ return;
200
+ }
201
+ if (!isPlainObject(node))
202
+ return;
203
+ for (const [key, value] of Object.entries(node)) {
204
+ stack.push(key);
205
+ if (keys.has(key))
206
+ record(key, value, node);
207
+ walk(value);
208
+ stack.pop();
209
+ }
210
+ };
211
+ // Anchored containment, enforced by where the walk STARTS: everything the
212
+ // index holds is at or below a top-level key some rule named. A wildcard root
213
+ // starts everywhere instead — bounded not by where the walk begins but by the
214
+ // schema region and the reserved key its rule had to declare to earn it.
215
+ const everywhere = roots.has(MATCH_ANY);
216
+ for (const [key, value] of Object.entries(document)) {
217
+ if (!everywhere && !roots.has(key))
218
+ continue;
219
+ stack.push(key);
220
+ if (keys.has(key))
221
+ record(key, value, document);
222
+ walk(value);
223
+ stack.pop();
224
+ }
225
+ return index;
226
+ }
227
+ /** The rules in `entries` that may match a document of `kind`, with the key and
228
+ * root sets their shared index needs. Empty rules mean the document is not
229
+ * walked at all. */
230
+ export function applicableRules(rules, kind) {
231
+ const applicable = [];
232
+ const keys = new Set();
233
+ const roots = new Set();
234
+ if (typeof kind !== "string")
235
+ return { rules: applicable, keys, roots };
236
+ for (const rule of rules) {
237
+ if (!rule.match.inKind.includes(kind) && !rule.match.inKind.includes(MATCH_ANY))
238
+ continue;
239
+ applicable.push(rule);
240
+ keys.add(rule.match.key);
241
+ for (const root of rule.match.under)
242
+ roots.add(root);
243
+ }
244
+ return { rules: applicable, keys, roots };
245
+ }
246
+ function valueMatches(match, value) {
247
+ if (Object.hasOwn(match, "value"))
248
+ return deepEquals(match.value, value);
249
+ if (match.valueOneOf)
250
+ return match.valueOneOf.some((candidate) => deepEquals(candidate, value));
251
+ return true;
252
+ }
253
+ /** The sites in `index` this match selects, in document order. */
254
+ export function selectMatches(index, document, match) {
255
+ const kind = isPlainObject(document) ? document.kind : undefined;
256
+ if (typeof kind !== "string")
257
+ return [];
258
+ if (!match.inKind.includes(kind) && !match.inKind.includes(MATCH_ANY))
259
+ return [];
260
+ const sites = index.get(match.key);
261
+ if (!sites)
262
+ return [];
263
+ const anyRoot = match.under.includes(MATCH_ANY);
264
+ const out = [];
265
+ for (const site of sites) {
266
+ // The index may be shared with rules naming other regions, so the anchor is
267
+ // re-checked per rule. A numeric first segment cannot occur — a document is
268
+ // a mapping — but the guard keeps the containment claim independent of that.
269
+ const anchor = site.path[0];
270
+ if (!anyRoot && (typeof anchor !== "string" || !match.under.includes(anchor)))
271
+ continue;
272
+ // The schema region — the same ancestry rule every schema-bounded surface
273
+ // reads, so the migration and the validators cannot disagree about where a
274
+ // schema is.
275
+ if (match.inSchema && !isInSchemaRegion(site.path))
276
+ continue;
277
+ // `notUnder` subtracts within the region, so it reads the whole path. A
278
+ // numeric segment never equals a key name, so the raw path is enough.
279
+ if (match.notUnder?.some((segment) => site.path.includes(segment)))
280
+ continue;
281
+ if (!valueMatches(match, site.value))
282
+ continue;
283
+ if (match.withSibling !== undefined && !Object.hasOwn(site.parent, match.withSibling))
284
+ continue;
285
+ out.push(site.path);
286
+ }
287
+ return out;
288
+ }
289
+ /** Convenience for a single match against a document — builds a one-rule index.
290
+ * The driver uses `applicableRules` + `buildMatchIndex` + `selectMatches` so
291
+ * one walk serves every rule that can reach the document. */
292
+ export function findMatches(document, match) {
293
+ const index = buildMatchIndex(document, new Set([match.key]), new Set(match.under));
294
+ return selectMatches(index, document, match);
295
+ }
@@ -0,0 +1,68 @@
1
+ /** Planning and applying a migration patch.
2
+ *
3
+ * Planning is separate from applying because a patch is ALL-OR-NOTHING at each
4
+ * match: a rule whose second operation cannot apply must not leave the first
5
+ * one's edit behind. `planPatch` resolves every operation against the tree
6
+ * first and returns either a concrete effect list or one refusal; only then
7
+ * does an applier run. The same plan drives both appliers — the JSON tree the
8
+ * loader hands the analyzer, and the YAML document `telo migrate` writes — so
9
+ * the in-memory rewrite and the on-disk repair cannot disagree about what an
10
+ * operation means. */
11
+ import type { MigrationOperation, MigrationPath, MigrationRefusal } from "./types.js";
12
+ /** A resolved, unconditionally applicable edit. */
13
+ export type MigrationEffect = {
14
+ readonly kind: "rename-key";
15
+ readonly parent: MigrationPath;
16
+ readonly from: string;
17
+ readonly to: string;
18
+ } | {
19
+ readonly kind: "set-value";
20
+ readonly path: MigrationPath;
21
+ readonly value: unknown;
22
+ } | {
23
+ readonly kind: "set-tag";
24
+ readonly path: MigrationPath;
25
+ readonly tag: string;
26
+ readonly source: string;
27
+ } | {
28
+ readonly kind: "insert-item";
29
+ readonly path: MigrationPath;
30
+ readonly index: number;
31
+ readonly value: unknown;
32
+ } | {
33
+ readonly kind: "remove-entry";
34
+ readonly path: MigrationPath;
35
+ };
36
+ export interface PatchPlan {
37
+ readonly effects: readonly MigrationEffect[];
38
+ /** Where the matched node lives after the patch. Differs from the matched
39
+ * path only when a `rename-key` moved it. */
40
+ readonly finalPath: MigrationPath;
41
+ /** The value at the match before anything was applied — the "before" half of
42
+ * the generated diagnostic sentence. */
43
+ readonly before: unknown;
44
+ /** The value at the match after the patch, or `undefined` for a removal. */
45
+ readonly after: unknown;
46
+ }
47
+ export type PatchPlanResult = {
48
+ readonly ok: true;
49
+ readonly plan: PatchPlan;
50
+ } | {
51
+ readonly ok: false;
52
+ readonly refusal: MigrationRefusal;
53
+ };
54
+ /**
55
+ * Resolve `ops` at `path` against `root`, or refuse.
56
+ *
57
+ * Refusing is the design's safety valve: the node is left exactly as the author
58
+ * wrote it and the ordinary validator reports it with an accurate message,
59
+ * rather than the migration guessing or dropping a value.
60
+ */
61
+ export declare function planPatch(root: unknown, path: MigrationPath, ops: readonly MigrationOperation[]): PatchPlanResult;
62
+ /** Apply a plan to the in-memory manifest tree. Mutates in place — the tree is
63
+ * the loader's own projection of the document, never the author's file. */
64
+ export declare function applyEffectsToTree(root: unknown, effects: readonly MigrationEffect[]): void;
65
+ /** Dotted rendering of a path, in the form the position index and every
66
+ * diagnostic's `data.path` use: `routes[0].handler`. */
67
+ export declare function formatMigrationPath(path: MigrationPath): string;
68
+ //# sourceMappingURL=patch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"patch.d.ts","sourceRoot":"","sources":["../../src/migrations/patch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;uBASuB;AAIvB,OAAO,KAAK,EACV,kBAAkB,EAClB,aAAa,EACb,gBAAgB,EACjB,MAAM,YAAY,CAAC;AAEpB,mDAAmD;AACnD,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAC3G;IAAE,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GACrF;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACzG;IAAE,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAC/G;IAAE,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAA;CAAE,CAAC;AAEpE,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;IAC7C;kDAC8C;IAC9C,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;IAClC;6CACyC;IACzC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,MAAM,eAAe,GACvB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC/C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAA;CAAE,CAAC;AAoD/D;;;;;;GAMG;AACH,wBAAgB,SAAS,CACvB,IAAI,EAAE,OAAO,EACb,IAAI,EAAE,aAAa,EACnB,GAAG,EAAE,SAAS,kBAAkB,EAAE,GACjC,eAAe,CA4FjB;AAED;4EAC4E;AAC5E,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,eAAe,EAAE,GAAG,IAAI,CA6C3F;AAUD;yDACyD;AACzD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,CAO/D"}
@@ -0,0 +1,236 @@
1
+ /** Planning and applying a migration patch.
2
+ *
3
+ * Planning is separate from applying because a patch is ALL-OR-NOTHING at each
4
+ * match: a rule whose second operation cannot apply must not leave the first
5
+ * one's edit behind. `planPatch` resolves every operation against the tree
6
+ * first and returns either a concrete effect list or one refusal; only then
7
+ * does an applier run. The same plan drives both appliers — the JSON tree the
8
+ * loader hands the analyzer, and the YAML document `telo migrate` writes — so
9
+ * the in-memory rewrite and the on-disk repair cannot disagree about what an
10
+ * operation means. */
11
+ import { makeTaggedSentinel } from "@telorun/templating";
12
+ import { deepEquals } from "./match.js";
13
+ function isPlainObject(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+ /** The container a path's last segment indexes into, or `undefined` when any
17
+ * hop is missing. An empty path has no container — the document root is not a
18
+ * mapping entry and no operation targets it. */
19
+ function containerOf(root, path) {
20
+ if (path.length === 0)
21
+ return undefined;
22
+ let current = root;
23
+ for (let i = 0; i < path.length - 1; i++) {
24
+ const segment = path[i];
25
+ if (typeof segment === "number") {
26
+ if (!Array.isArray(current))
27
+ return undefined;
28
+ current = current[segment];
29
+ }
30
+ else {
31
+ if (!isPlainObject(current))
32
+ return undefined;
33
+ current = current[segment];
34
+ }
35
+ }
36
+ return { container: current, key: path[path.length - 1] };
37
+ }
38
+ function readAt(root, path) {
39
+ const located = containerOf(root, path);
40
+ if (!located)
41
+ return { found: false, value: undefined };
42
+ const { container, key } = located;
43
+ if (typeof key === "number") {
44
+ if (!Array.isArray(container) || key < 0 || key >= container.length) {
45
+ return { found: false, value: undefined };
46
+ }
47
+ return { found: true, value: container[key] };
48
+ }
49
+ if (!isPlainObject(container) || !Object.hasOwn(container, key)) {
50
+ return { found: false, value: undefined };
51
+ }
52
+ return { found: true, value: container[key] };
53
+ }
54
+ /** A scalar YAML can carry behind a tag, and the only thing `set-tag` and
55
+ * `qualify` can operate on. */
56
+ function scalarSource(value) {
57
+ if (typeof value === "string")
58
+ return value;
59
+ if (typeof value === "number" || typeof value === "boolean")
60
+ return String(value);
61
+ return undefined;
62
+ }
63
+ /**
64
+ * Resolve `ops` at `path` against `root`, or refuse.
65
+ *
66
+ * Refusing is the design's safety valve: the node is left exactly as the author
67
+ * wrote it and the ordinary validator reports it with an accurate message,
68
+ * rather than the migration guessing or dropping a value.
69
+ */
70
+ export function planPatch(root, path, ops) {
71
+ const initial = readAt(root, path);
72
+ if (!initial.found)
73
+ return { ok: false, refusal: "path-not-found" };
74
+ const effects = [];
75
+ let currentPath = path;
76
+ let currentValue = initial.value;
77
+ let removed = false;
78
+ for (const op of ops) {
79
+ if (removed)
80
+ return { ok: false, refusal: "path-not-found" };
81
+ switch (op.op) {
82
+ case "rename-key": {
83
+ const located = containerOf(root, currentPath);
84
+ const key = located?.key;
85
+ if (!located || typeof key !== "string" || !isPlainObject(located.container)) {
86
+ return { ok: false, refusal: "not-a-mapping-entry" };
87
+ }
88
+ if (op.to !== key && Object.hasOwn(located.container, op.to)) {
89
+ // Refuse rather than replace: the occupied destination holds a value
90
+ // the author wrote.
91
+ return { ok: false, refusal: "destination-occupied" };
92
+ }
93
+ const parent = currentPath.slice(0, -1);
94
+ effects.push({ kind: "rename-key", parent, from: key, to: op.to });
95
+ currentPath = [...parent, op.to];
96
+ break;
97
+ }
98
+ case "set-value": {
99
+ let next;
100
+ if (op.qualify !== undefined) {
101
+ if (typeof currentValue !== "string") {
102
+ return { ok: false, refusal: "malformed-value" };
103
+ }
104
+ if (currentValue.startsWith(op.qualify)) {
105
+ return { ok: false, refusal: "nothing-to-rewrite" };
106
+ }
107
+ next = `${op.qualify}${currentValue}`;
108
+ }
109
+ else {
110
+ // Same refusal as `qualify`'s, and for the same reason: a rule should
111
+ // match only the legacy spelling, so a write of the value already
112
+ // there means the matcher was too wide. Reporting it would be a
113
+ // deprecation reading `type: string is now written type: string`.
114
+ if (deepEquals(op.value, currentValue)) {
115
+ return { ok: false, refusal: "nothing-to-rewrite" };
116
+ }
117
+ next = op.value;
118
+ }
119
+ effects.push({ kind: "set-value", path: currentPath, value: next });
120
+ currentValue = next;
121
+ break;
122
+ }
123
+ case "set-tag": {
124
+ const source = scalarSource(currentValue);
125
+ if (source === undefined)
126
+ return { ok: false, refusal: "not-a-scalar" };
127
+ effects.push({ kind: "set-tag", path: currentPath, tag: op.tag, source });
128
+ currentValue = makeTaggedSentinel(op.tag, source);
129
+ break;
130
+ }
131
+ case "insert-item": {
132
+ if (!Array.isArray(currentValue))
133
+ return { ok: false, refusal: "not-a-sequence" };
134
+ const index = op.at === undefined
135
+ ? currentValue.length
136
+ : Math.max(0, Math.min(op.at, currentValue.length));
137
+ effects.push({ kind: "insert-item", path: currentPath, index, value: op.value });
138
+ currentValue = [
139
+ ...currentValue.slice(0, index),
140
+ op.value,
141
+ ...currentValue.slice(index),
142
+ ];
143
+ break;
144
+ }
145
+ case "remove-entry": {
146
+ effects.push({ kind: "remove-entry", path: currentPath });
147
+ currentValue = undefined;
148
+ removed = true;
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ return {
154
+ ok: true,
155
+ plan: {
156
+ effects,
157
+ finalPath: currentPath,
158
+ before: initial.value,
159
+ after: removed ? undefined : currentValue,
160
+ },
161
+ };
162
+ }
163
+ /** Apply a plan to the in-memory manifest tree. Mutates in place — the tree is
164
+ * the loader's own projection of the document, never the author's file. */
165
+ export function applyEffectsToTree(root, effects) {
166
+ for (const effect of effects) {
167
+ switch (effect.kind) {
168
+ case "rename-key": {
169
+ const parent = effect.parent.length === 0 ? root : readAt(root, effect.parent).value;
170
+ if (!isPlainObject(parent))
171
+ continue;
172
+ // Rebuilt in place so the renamed key keeps its position. Key order is
173
+ // what a round-trip consumer and a rendered diff both read.
174
+ const entries = Object.entries(parent);
175
+ for (const [key] of entries)
176
+ delete parent[key];
177
+ for (const [key, value] of entries) {
178
+ parent[key === effect.from ? effect.to : key] = value;
179
+ }
180
+ break;
181
+ }
182
+ case "set-value":
183
+ case "set-tag": {
184
+ const located = containerOf(root, effect.path);
185
+ if (!located)
186
+ continue;
187
+ const next = effect.kind === "set-value"
188
+ ? effect.value
189
+ : makeTaggedSentinel(effect.tag, effect.source);
190
+ writeAt(located.container, located.key, next);
191
+ break;
192
+ }
193
+ case "insert-item": {
194
+ const target = readAt(root, effect.path).value;
195
+ if (!Array.isArray(target))
196
+ continue;
197
+ target.splice(effect.index, 0, effect.value);
198
+ break;
199
+ }
200
+ case "remove-entry": {
201
+ const located = containerOf(root, effect.path);
202
+ if (!located)
203
+ continue;
204
+ if (typeof located.key === "number") {
205
+ if (Array.isArray(located.container))
206
+ located.container.splice(located.key, 1);
207
+ }
208
+ else if (isPlainObject(located.container)) {
209
+ delete located.container[located.key];
210
+ }
211
+ break;
212
+ }
213
+ }
214
+ }
215
+ }
216
+ function writeAt(container, key, value) {
217
+ if (typeof key === "number") {
218
+ if (Array.isArray(container))
219
+ container[key] = value;
220
+ return;
221
+ }
222
+ if (isPlainObject(container))
223
+ container[key] = value;
224
+ }
225
+ /** Dotted rendering of a path, in the form the position index and every
226
+ * diagnostic's `data.path` use: `routes[0].handler`. */
227
+ export function formatMigrationPath(path) {
228
+ let out = "";
229
+ for (const segment of path) {
230
+ if (typeof segment === "number")
231
+ out += `[${segment}]`;
232
+ else
233
+ out += out.length === 0 ? segment : `.${segment}`;
234
+ }
235
+ return out;
236
+ }
@@ -0,0 +1,25 @@
1
+ /** Path provenance — part of the driver's contract, not an optional extra.
2
+ *
3
+ * `resolveRange` looks a diagnostic's dotted path up in a position index built
4
+ * from the RAW file, then falls back to the parent's key or value range. Every
5
+ * rewrite that existed before migrations preserved paths; a `rename-key` is
6
+ * the first that does not, and without a remap every downstream diagnostic on
7
+ * that node degrades to a parent squiggle — with a `DiagnosticFix` over a
8
+ * single-line parent value writing a whole value across it.
9
+ *
10
+ * So each rewrite records the legacy path it matched alongside the migrated
11
+ * one, and diagnostics are remapped through that record before position
12
+ * resolution. This is the generalization of `rewriteSyntheticOrigins`, which
13
+ * already rewrites `data.path` so lookups resolve after
14
+ * `normalizeInlineResources` has moved a resource. */
15
+ import type { AnalysisDiagnostic } from "../types.js";
16
+ import type { LoadedGraph } from "../loaded-types.js";
17
+ /**
18
+ * Rewrite every diagnostic's `data.path` from the migrated spelling back to
19
+ * what the author wrote, so position lookups against the raw file resolve.
20
+ *
21
+ * A no-op — and returns the input array — when nothing in the graph was
22
+ * migrated, which is the overwhelmingly common case.
23
+ */
24
+ export declare function remapMigratedPaths(graph: LoadedGraph, diagnostics: readonly AnalysisDiagnostic[]): AnalysisDiagnostic[];
25
+ //# sourceMappingURL=provenance.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provenance.d.ts","sourceRoot":"","sources":["../../src/migrations/provenance.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;uDAauD;AAEvD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAsHtD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,WAAW,EAClB,WAAW,EAAE,SAAS,kBAAkB,EAAE,GACzC,kBAAkB,EAAE,CAsBtB"}