@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,262 @@
1
+ /** The migration driver — one pass, one set of guarantees.
2
+ *
3
+ * **Composition is the driver's guarantee, not each entry's proof
4
+ * obligation.** The match set is frozen against the pre-migration tree, so no
5
+ * rule can match a node another rule produced; rules within an entry apply in
6
+ * order at each match; entries never see one another's output. That matters
7
+ * once core and module entries are aggregated from different parties, where
8
+ * "it happens to work" is not determinism. Idempotency then follows from the
9
+ * driver rather than from every author getting it right: a rule matches only
10
+ * the legacy spelling, and re-running finds nothing.
11
+ *
12
+ * How a rewrite is REPORTED — the provenance record and the diagnostic — is
13
+ * `report.ts`. Nothing there can change what a migration does, and nothing
14
+ * here decides how it reads. */
15
+
16
+ import type { ResourceManifest } from "@telorun/sdk";
17
+ import type { Document } from "yaml";
18
+ import {
19
+ applyEffectsToTree,
20
+ formatMigrationPath,
21
+ planPatch,
22
+ type MigrationEffect,
23
+ } from "./patch.js";
24
+ import { applyTextEdits, planTextEdits, type TextEdit } from "./yaml-edit.js";
25
+ import { applicableRules, buildMatchIndex, selectMatches } from "./match.js";
26
+ import { CORE_MIGRATIONS } from "./registry.js";
27
+ import { toDiagnostic, toRewrite, type AppliedPatch } from "./report.js";
28
+ import type {
29
+ MigrationEntry,
30
+ MigrationOperation,
31
+ MigrationPath,
32
+ MigrationRewrite,
33
+ } from "./types.js";
34
+ import type { AnalysisDiagnostic } from "../types.js";
35
+
36
+ /** What one file's migration produced. */
37
+ export interface FileMigrations {
38
+ /** One record per applied rewrite, in application order. Empty when the file
39
+ * carried no legacy spelling — the overwhelmingly common case. */
40
+ readonly rewrites: readonly MigrationRewrite[];
41
+ /** One diagnostic per rewrite. Whether they are SURFACED is the graph's
42
+ * decision: a migration rewrites always, because the runtime must read
43
+ * artifacts published years ago, but reports only for the entry's own
44
+ * modules, because a published dependency is not the consumer's to fix. */
45
+ readonly diagnostics: readonly AnalysisDiagnostic[];
46
+ }
47
+
48
+ export const NO_MIGRATIONS: FileMigrations = { rewrites: [], diagnostics: [] };
49
+
50
+ /**
51
+ * Run `entries` over one file's parsed documents, mutating `manifests` in
52
+ * place. Returns the provenance records and their diagnostics.
53
+ *
54
+ * The manifests are the loader's own projection of the file, never the
55
+ * author's text — nothing here can reach disk.
56
+ */
57
+ export function migrateManifests(args: {
58
+ source: string;
59
+ manifests: Array<ResourceManifest | null>;
60
+ entries?: readonly MigrationEntry[];
61
+ }): FileMigrations {
62
+ const entries = args.entries ?? CORE_MIGRATIONS;
63
+ if (entries.length === 0) return NO_MIGRATIONS;
64
+
65
+ const applied = applyAll(args.manifests, entries);
66
+ if (applied.length === 0) return NO_MIGRATIONS;
67
+
68
+ const rewrites = applied.map((a) => toRewrite(a));
69
+ const diagnostics = applied.map((a, i) =>
70
+ toDiagnostic(a, rewrites[i]!, args.source, args.manifests),
71
+ );
72
+ return { rewrites, diagnostics };
73
+ }
74
+
75
+ /**
76
+ * Migrate the author's YAML instead of the loader's tree — the operation
77
+ * `telo migrate` is the reference application of.
78
+ *
79
+ * `manifests` must be the RAW (un-migrated) projection of `documents`, since
80
+ * the matchers select legacy spellings. Returns `null` when nothing matched, so
81
+ * a caller can leave an untouched file untouched rather than rewriting
82
+ * identical bytes.
83
+ *
84
+ * `unwritable` names every rewrite the tree accepted but the TEXT could not
85
+ * express. The two appliers can disagree only in this direction, and the
86
+ * disagreement has to be reported: the diagnostic that sent the author here
87
+ * says "run `telo migrate`", so a location this silently skipped would keep
88
+ * warning with no way to act on it.
89
+ */
90
+ export function migrateFileText(args: {
91
+ source: string;
92
+ text: string;
93
+ documents: readonly Document[];
94
+ manifests: ReadonlyArray<ResourceManifest | null>;
95
+ entries?: readonly MigrationEntry[];
96
+ }): {
97
+ text: string;
98
+ rewrites: MigrationRewrite[];
99
+ unwritable: MigrationRewrite[];
100
+ } | null {
101
+ const entries = args.entries ?? CORE_MIGRATIONS;
102
+ if (entries.length === 0) return null;
103
+
104
+ // Planned against a copy: the tree walk is what decides which patches apply
105
+ // (and in what order they refuse), so the YAML side must ask exactly the same
106
+ // question of exactly the same state rather than re-deriving it.
107
+ const scratch = args.manifests.map((m) => (m ? (structuredClone(m) as ResourceManifest) : null));
108
+ const applied = applyAll(scratch, entries);
109
+ if (applied.length === 0) return null;
110
+
111
+ const edits: TextEdit[] = [];
112
+ const kept: AppliedPatch[] = [];
113
+ const skipped: AppliedPatch[] = [];
114
+ for (const patch of applied) {
115
+ const doc = args.documents[patch.documentIndex];
116
+ if (!doc) {
117
+ skipped.push(patch);
118
+ continue;
119
+ }
120
+ const planned = planTextEdits(doc, args.text, patch.plan.effects);
121
+ // A patch the tree accepted but the text cannot express (a block scalar
122
+ // span, a flow-style entry) leaves that node alone rather than half-written.
123
+ if (!planned) {
124
+ skipped.push(patch);
125
+ continue;
126
+ }
127
+ // Two patches whose spans overlap cannot both be spliced — the second would
128
+ // write into bytes the first replaced. The tree side has no equivalent,
129
+ // because an object write is idempotent where a splice is positional, so
130
+ // this is the one place the file applier is stricter: the later patch is
131
+ // dropped and its node stays as the author wrote it.
132
+ if (planned.some((edit) => edits.some((existing) => overlaps(edit, existing)))) {
133
+ skipped.push(patch);
134
+ continue;
135
+ }
136
+ edits.push(...planned);
137
+ kept.push(patch);
138
+ }
139
+ if (edits.length === 0 && skipped.length === 0) return null;
140
+
141
+ return {
142
+ text: edits.length > 0 ? applyTextEdits(args.text, edits) : args.text,
143
+ rewrites: kept.map(toRewrite),
144
+ unwritable: skipped.map(toRewrite),
145
+ };
146
+ }
147
+
148
+ /** Whether two splices contend for the same bytes. A pure insertion (an empty
149
+ * span) collides only with a span that strictly contains its point. */
150
+ function overlaps(a: TextEdit, b: TextEdit): boolean {
151
+ if (a.start === a.end) return b.start < a.start && a.start < b.end;
152
+ if (b.start === b.end) return a.start < b.start && b.start < a.end;
153
+ return a.start < b.end && b.start < a.end;
154
+ }
155
+
156
+ /** One pass: every match collected against the frozen pre-migration tree, then
157
+ * applied in entry order. */
158
+ function applyAll(
159
+ manifests: Array<ResourceManifest | null>,
160
+ entries: readonly MigrationEntry[],
161
+ ): AppliedPatch[] {
162
+ const candidates: Array<{
163
+ entry: MigrationEntry;
164
+ ops: readonly MigrationOperation[];
165
+ documentIndex: number;
166
+ path: MigrationPath;
167
+ }> = [];
168
+
169
+ // One index per DOCUMENT, shared by every rule that can reach it — the walk
170
+ // is the expensive part and does not depend on the rule. This runs on the
171
+ // kernel's boot path for every file in the graph, so a walk per rule would
172
+ // scale the cost of loading any manifest with the size of the migration set.
173
+ //
174
+ // The `inKind` gate and the `under` regions are both known BEFORE the walk,
175
+ // so they bound it rather than filtering its output: a document no rule
176
+ // targets is never walked, and a region no rule names is never descended
177
+ // into. `type:` alone occurs a couple of hundred times in a standard-library
178
+ // manifest, so indexing sites that cannot be selected is the whole cost.
179
+ const flatRules = entries.flatMap((entry) =>
180
+ entry.rules.map((rule) => ({ entry, ops: rule.patch, match: rule.match })),
181
+ );
182
+ for (let documentIndex = 0; documentIndex < manifests.length; documentIndex++) {
183
+ const manifest = manifests[documentIndex];
184
+ if (!manifest) continue;
185
+ const { rules, keys, roots } = applicableRules(flatRules, manifest.kind);
186
+ if (rules.length === 0) continue;
187
+ const index = buildMatchIndex(manifest, keys, roots);
188
+ for (const { entry, ops, match } of rules) {
189
+ for (const path of selectMatches(index, manifest, match)) {
190
+ candidates.push({ entry, ops, documentIndex, path });
191
+ }
192
+ }
193
+ }
194
+
195
+ // Arrays whose LENGTH an already-applied patch changed, as
196
+ // `<documentIndex>:<dotted path>`. A frozen match names a sequence element by
197
+ // INDEX, and an index is not an identity: once a sibling patch inserted or
198
+ // removed an item, the same index names a different element — including one
199
+ // another rule just produced, which is exactly what the frozen match set
200
+ // exists to prevent. A key-based path needs no such record, because a rename
201
+ // or removal makes the stale path resolve to nothing and `planPatch` refuses
202
+ // it on its own.
203
+ const shiftedArrays = new Set<string>();
204
+
205
+ const applied: AppliedPatch[] = [];
206
+ for (const candidate of candidates) {
207
+ const manifest = manifests[candidate.documentIndex];
208
+ if (!manifest) continue;
209
+ if (indexIsStale(candidate.documentIndex, candidate.path, shiftedArrays)) continue;
210
+ const result = planPatch(manifest, candidate.path, candidate.ops);
211
+ // Refusal is not an error: the node stays as the author wrote it and the
212
+ // ordinary validator reports it with an accurate message.
213
+ if (!result.ok) continue;
214
+ applyEffectsToTree(manifest, result.plan.effects);
215
+ for (const array of resizedArrays(candidate.documentIndex, result.plan.effects)) {
216
+ shiftedArrays.add(array);
217
+ }
218
+ applied.push({
219
+ entry: candidate.entry,
220
+ documentIndex: candidate.documentIndex,
221
+ matched: candidate.path,
222
+ plan: result.plan,
223
+ ops: candidate.ops,
224
+ });
225
+ }
226
+ return applied;
227
+ }
228
+
229
+ /** Arrays this patch resized, keyed for `shiftedArrays`. */
230
+ function resizedArrays(
231
+ documentIndex: number,
232
+ effects: readonly MigrationEffect[],
233
+ ): string[] {
234
+ const out: string[] = [];
235
+ for (const effect of effects) {
236
+ if (effect.kind === "insert-item") {
237
+ out.push(`${documentIndex}:${formatMigrationPath(effect.path)}`);
238
+ } else if (
239
+ effect.kind === "remove-entry" &&
240
+ typeof effect.path[effect.path.length - 1] === "number"
241
+ ) {
242
+ out.push(`${documentIndex}:${formatMigrationPath(effect.path.slice(0, -1))}`);
243
+ }
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /** Whether any index along `path` steps into an array a prior patch resized. */
249
+ function indexIsStale(
250
+ documentIndex: number,
251
+ path: MigrationPath,
252
+ shiftedArrays: ReadonlySet<string>,
253
+ ): boolean {
254
+ if (shiftedArrays.size === 0) return false;
255
+ for (let i = 0; i < path.length; i++) {
256
+ if (typeof path[i] !== "number") continue;
257
+ if (shiftedArrays.has(`${documentIndex}:${formatMigrationPath(path.slice(0, i))}`)) {
258
+ return true;
259
+ }
260
+ }
261
+ return false;
262
+ }
@@ -0,0 +1,10 @@
1
+ // GENERATED by scripts/copy-migration-entries.mjs — do not edit, and do not commit.
2
+ // Source: analyzer/migrations/*.json (lexically ordered).
3
+ import e0 from "./normalize-value-types.json" with { type: "json" };
4
+ import e1 from "./ref-slot-scalar-type.json" with { type: "json" };
5
+
6
+ /** Every entry file, in the order the driver applies them. */
7
+ export const MIGRATION_ENTRY_FILES: ReadonlyArray<readonly [file: string, data: unknown]> = [
8
+ ["normalize-value-types.json", e0],
9
+ ["ref-slot-scalar-type.json", e1],
10
+ ];
@@ -0,0 +1,48 @@
1
+ {
2
+ "$comment": "Three keywords answered one question — what is the value at this slot, beyond what JSON Schema's `type` can say — and each answered it differently. `inSchema` with the wildcard `inKind` / `under` is what makes this reachable at all: these annotations occur in author-written schema fragments inside ORDINARY resource documents (an inline `inputType:` on any kind that declares one, a route's `request.schema.body`), and that set of kinds is open, so enumerating it would be both incomplete and a violation of the topology-driven constraint. The wildcards are earned by the pairing the reader enforces: a schema region bounds where the walk may go, and an `x-telo-*` key — Telo vocabulary wherever it appears — bounds what it may touch. Each rule sets the value BEFORE renaming the key, so both match the frozen pre-migration tree; a rename first would leave the second rule looking for a node that no longer exists. `notUnder` keeps all three out of the data-bearing keywords, where a value may merely look like a schema.",
3
+ "id": "normalize-value-types",
4
+ "code": "X_TELO_TYPE_DEPRECATED",
5
+ "severity": "warning",
6
+ "reason": "The three value-type annotations were unified into one `x-telo-type`, whose names are `Telo.`-qualified like every other kernel-owned name. `x-telo-binary` and `x-telo-stream` said what a value IS just as `x-telo-type` did, differing only in posture toward the JSON Schema layer — so a fourth kind of value meant a fourth keyword and edits across four packages.",
7
+ "rules": [
8
+ {
9
+ "match": {
10
+ "key": "x-telo-stream",
11
+ "inKind": ["*"],
12
+ "under": ["*"],
13
+ "inSchema": true,
14
+ "value": true,
15
+ "notUnder": ["const", "default", "enum", "examples"]
16
+ },
17
+ "patch": [
18
+ { "op": "set-value", "value": "Telo.Stream" },
19
+ { "op": "rename-key", "to": "x-telo-type" }
20
+ ]
21
+ },
22
+ {
23
+ "match": {
24
+ "key": "x-telo-binary",
25
+ "inKind": ["*"],
26
+ "under": ["*"],
27
+ "inSchema": true,
28
+ "value": true,
29
+ "notUnder": ["const", "default", "enum", "examples"]
30
+ },
31
+ "patch": [
32
+ { "op": "set-value", "value": "Telo.Bytes" },
33
+ { "op": "rename-key", "to": "x-telo-type" }
34
+ ]
35
+ },
36
+ {
37
+ "match": {
38
+ "key": "x-telo-type",
39
+ "inKind": ["*"],
40
+ "under": ["*"],
41
+ "inSchema": true,
42
+ "valueOneOf": ["TcpPort", "UdpPort"],
43
+ "notUnder": ["const", "default", "enum", "examples"]
44
+ },
45
+ "patch": [{ "op": "set-value", "qualify": "Telo." }]
46
+ }
47
+ ]
48
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$comment": "See README.md in this directory. `inKind` + `under` are the positive containment: `under` names TOP-LEVEL keys, so this rule can reach nothing but the schema blocks of a kind document — never the `resources:` template body, whose nested config may hold a key spelled `schema` over data. `withSibling` is what makes it precise — a scalar `type:` is stale only because an `x-telo-ref` sits beside it. `notUnder` then narrows within that region, keeping the rule out of the data-bearing keywords where a value may merely look like a schema.",
3
+ "id": "ref-slot-scalar-type",
4
+ "code": "X_TELO_REF_SCALAR_TYPE",
5
+ "severity": "warning",
6
+ "reason": "A reference is written with the `!ref` tag and resolves to an object, so a reference slot no longer carries the scalar `type:` that plain-string references were pinned to. Leaving it in place makes the slot reject the very value it is declared to accept, and every schema reader has had to know to ignore it.",
7
+ "rules": [
8
+ {
9
+ "match": {
10
+ "key": "type",
11
+ "inKind": ["Telo.Definition", "Telo.Abstract"],
12
+ "under": ["schema", "status", "inputType", "outputType"],
13
+ "valueOneOf": ["string", "number", "integer", "boolean"],
14
+ "withSibling": "x-telo-ref",
15
+ "notUnder": ["const", "default", "enum", "examples"]
16
+ },
17
+ "patch": [{ "op": "remove-entry" }]
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,207 @@
1
+ /** The on-disk shape of a migration entry, and its reader.
2
+ *
3
+ * An entry is DATA: one file per entry under `analyzer/migrations/`, consumed
4
+ * as one lexically ordered set. That is what makes adding a migration one file
5
+ * and retiring one deleting it, what ships the set in the published package,
6
+ * and what lets the Rust crate embed the identical files at build time rather
7
+ * than reimplementing each rewrite.
8
+ *
9
+ * **An entry contains no code.** Both halves — what a rule matches and what it
10
+ * patches — are data, so one file is read identically by every kernel. A
11
+ * predicate expressed in one language would mean one artifact is read two
12
+ * ways, invisibly, since a migration that succeeds is silent.
13
+ *
14
+ * Reading is STRICT. A malformed entry is an authoring mistake, and the
15
+ * alternative to throwing is a migration that silently does not run — the one
16
+ * failure mode a rewrite-on-load design cannot afford, because a migration
17
+ * that succeeds and one that never fires look identical. */
18
+
19
+ import { DiagnosticSeverity } from "../types.js";
20
+ import { readMigrationMatch } from "./match.js";
21
+ import type {
22
+ MigrationEntry,
23
+ MigrationOperation,
24
+ MigrationRule,
25
+ } from "./types.js";
26
+ import { MIGRATION_OPS } from "./types.js";
27
+
28
+ /** How an entry file names a severity. The LSP integers are a transport detail
29
+ * of one editor; an entry is read by a Rust crate too. */
30
+ const SEVERITIES: Record<string, DiagnosticSeverity> = {
31
+ error: DiagnosticSeverity.Error,
32
+ warning: DiagnosticSeverity.Warning,
33
+ info: DiagnosticSeverity.Information,
34
+ hint: DiagnosticSeverity.Hint,
35
+ };
36
+
37
+ class MigrationEntryError extends Error {
38
+ constructor(file: string, detail: string) {
39
+ super(`Invalid migration entry '${file}': ${detail}`);
40
+ this.name = "MigrationEntryError";
41
+ }
42
+ }
43
+
44
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === "object" && value !== null && !Array.isArray(value);
46
+ }
47
+
48
+ function requireString(file: string, node: Record<string, unknown>, key: string): string {
49
+ const value = node[key];
50
+ if (typeof value !== "string" || value.length === 0) {
51
+ throw new MigrationEntryError(file, `'${key}' must be a non-empty string`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ const OPERATION_KEYS: Record<(typeof MIGRATION_OPS)[number], readonly string[]> = {
57
+ "rename-key": ["to"],
58
+ "set-value": ["value", "qualify"],
59
+ "set-tag": ["tag"],
60
+ "insert-item": ["value", "at"],
61
+ "remove-entry": [],
62
+ };
63
+
64
+ /** Top-level entry keys. `$comment` is the sanctioned place for author notes —
65
+ * the reader ignores it, but it has to be DECLARED, or "unknown keys are
66
+ * refused" would be true of every level but this one. */
67
+ const ENTRY_KEYS = ["id", "code", "severity", "reason", "rules", "$comment"] as const;
68
+
69
+ /**
70
+ * A value a patch may write.
71
+ *
72
+ * Scalars only, because "every operation has a known YAML edit form" is what
73
+ * makes a migration applicable to a FILE, and the file applier renders a value
74
+ * by re-quoting it in the author's own style at the node's own span — which has
75
+ * no meaning for a mapping or a sequence. Without this check the limitation is
76
+ * invisible until a user runs `telo migrate` and is told, permanently, to fix
77
+ * it by hand; with it, the entry's author learns at authoring time. Structured
78
+ * values are a vocabulary extension (a block renderer), not a silent gap.
79
+ */
80
+ function requireScalarValue(file: string, index: number, op: string, value: unknown): void {
81
+ if (
82
+ value === null ||
83
+ typeof value === "string" ||
84
+ typeof value === "number" ||
85
+ typeof value === "boolean"
86
+ ) {
87
+ return;
88
+ }
89
+ throw new MigrationEntryError(
90
+ file,
91
+ `patch[${index}] ('${op}') value must be a scalar (string, number, boolean or null) — ` +
92
+ `a mapping or sequence has no in-place YAML edit form, so \`telo migrate\` could never apply it`,
93
+ );
94
+ }
95
+
96
+ function readOperation(file: string, raw: unknown, index: number): MigrationOperation {
97
+ if (!isPlainObject(raw)) {
98
+ throw new MigrationEntryError(file, `patch[${index}] must be a mapping`);
99
+ }
100
+ const op = raw.op;
101
+ if (typeof op !== "string" || !(MIGRATION_OPS as readonly string[]).includes(op)) {
102
+ throw new MigrationEntryError(
103
+ file,
104
+ `patch[${index}].op '${String(op)}' is not one of ${MIGRATION_OPS.join(", ")}`,
105
+ );
106
+ }
107
+ // The vocabulary is closed, so an unknown parameter is a typo — and a typo in
108
+ // a patch is a rewrite that quietly does something other than what it reads
109
+ // as. `set-value`'s two parameters are alternatives, checked below.
110
+ const allowed = new Set<string>([...OPERATION_KEYS[op as (typeof MIGRATION_OPS)[number]], "op"]);
111
+ for (const key of Object.keys(raw)) {
112
+ if (!allowed.has(key)) {
113
+ throw new MigrationEntryError(file, `patch[${index}] ('${op}') has no parameter '${key}'`);
114
+ }
115
+ }
116
+
117
+ switch (op) {
118
+ case "rename-key":
119
+ return { op, to: requireString(file, raw, "to") };
120
+ case "set-value": {
121
+ const hasValue = Object.hasOwn(raw, "value");
122
+ const hasQualify = Object.hasOwn(raw, "qualify");
123
+ if (hasValue === hasQualify) {
124
+ throw new MigrationEntryError(
125
+ file,
126
+ `patch[${index}] ('set-value') takes exactly one of 'value' or 'qualify'`,
127
+ );
128
+ }
129
+ if (hasQualify) return { op, qualify: requireString(file, raw, "qualify") };
130
+ requireScalarValue(file, index, op, raw.value);
131
+ return { op, value: raw.value };
132
+ }
133
+ case "set-tag":
134
+ return { op, tag: requireString(file, raw, "tag") };
135
+ case "insert-item": {
136
+ if (!Object.hasOwn(raw, "value")) {
137
+ throw new MigrationEntryError(file, `patch[${index}] ('insert-item') requires 'value'`);
138
+ }
139
+ requireScalarValue(file, index, op, raw.value);
140
+ const at = raw.at;
141
+ if (at !== undefined && (typeof at !== "number" || !Number.isInteger(at) || at < 0)) {
142
+ throw new MigrationEntryError(
143
+ file,
144
+ `patch[${index}].at must be a non-negative integer when present`,
145
+ );
146
+ }
147
+ return at === undefined ? { op, value: raw.value } : { op, value: raw.value, at };
148
+ }
149
+ case "remove-entry":
150
+ return { op };
151
+ default:
152
+ throw new MigrationEntryError(file, `patch[${index}].op '${op}' is unhandled`);
153
+ }
154
+ }
155
+
156
+ function readRule(file: string, raw: unknown, index: number): MigrationRule {
157
+ if (!isPlainObject(raw)) {
158
+ throw new MigrationEntryError(file, `rules[${index}] must be a mapping`);
159
+ }
160
+ if (!Array.isArray(raw.patch) || raw.patch.length === 0) {
161
+ throw new MigrationEntryError(file, `rules[${index}].patch must be a non-empty sequence`);
162
+ }
163
+ return {
164
+ match: readMigrationMatch(`Invalid migration entry '${file}': rules[${index}]`, raw.match),
165
+ patch: raw.patch.map((op, i) => readOperation(file, op, i)),
166
+ };
167
+ }
168
+
169
+ /**
170
+ * Read one entry file's parsed data into a `MigrationEntry`.
171
+ *
172
+ * `file` names the entry file, so a failure says which one.
173
+ */
174
+ export function parseMigrationEntry(file: string, data: unknown): MigrationEntry {
175
+ if (!isPlainObject(data)) {
176
+ throw new MigrationEntryError(file, "an entry must be a mapping");
177
+ }
178
+ // Closed at every level, this one included: a typo'd top-level key would
179
+ // otherwise be silently ignored, which is the one failure a rewrite-on-load
180
+ // design cannot afford once module-shipped entries make this a trust boundary.
181
+ for (const key of Object.keys(data)) {
182
+ if (!(ENTRY_KEYS as readonly string[]).includes(key)) {
183
+ throw new MigrationEntryError(
184
+ file,
185
+ `an entry has no key '${key}'. Known keys: ${ENTRY_KEYS.join(", ")}.`,
186
+ );
187
+ }
188
+ }
189
+ const severityName = requireString(file, data, "severity");
190
+ const severity = SEVERITIES[severityName];
191
+ if (severity === undefined) {
192
+ throw new MigrationEntryError(
193
+ file,
194
+ `severity '${severityName}' must be one of ${Object.keys(SEVERITIES).join(", ")}`,
195
+ );
196
+ }
197
+ if (!Array.isArray(data.rules) || data.rules.length === 0) {
198
+ throw new MigrationEntryError(file, "'rules' must be a non-empty sequence");
199
+ }
200
+ return {
201
+ id: requireString(file, data, "id"),
202
+ code: requireString(file, data, "code"),
203
+ severity,
204
+ reason: requireString(file, data, "reason").trim(),
205
+ rules: data.rules.map((rule, i) => readRule(file, rule, i)),
206
+ };
207
+ }
@@ -0,0 +1,22 @@
1
+ /** The migration mechanism's surface — what a consumer OUTSIDE this directory
2
+ * may hold.
3
+ *
4
+ * Deliberately narrow. The matcher, the patch planner and the two appliers are
5
+ * one mechanism with one entry point per job (`migrateManifests` for the
6
+ * loader, `migrateFileText` for `telo migrate`, `remapMigratedPaths` for a
7
+ * diagnostic consumer); re-exporting their internals would make the plan/apply
8
+ * split, the effect vocabulary and the text-edit shape semver-bound API with
9
+ * no caller, and freeze the one part most likely to change as the operation
10
+ * vocabulary grows.
11
+ *
12
+ * The entry-set trio (`CORE_MIGRATIONS`, `parseMigrationEntry`,
13
+ * `MigrationEntry`) is here because `LoaderInitOptions.migrations` is a
14
+ * composition-root seam: a host aggregating module-shipped entries beside the
15
+ * core ones needs to read one and to name the set it is extending. */
16
+
17
+ export { migrateFileText, migrateManifests, NO_MIGRATIONS } from "./driver.js";
18
+ export type { FileMigrations } from "./driver.js";
19
+ export { parseMigrationEntry } from "./entry-data.js";
20
+ export { remapMigratedPaths } from "./provenance.js";
21
+ export { CORE_MIGRATIONS } from "./registry.js";
22
+ export type { MigrationEntry, MigrationRewrite } from "./types.js";