@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,167 @@
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
+
16
+ import type { AnalysisDiagnostic } from "../types.js";
17
+ import type { LoadedGraph } from "../loaded-types.js";
18
+ import type { MigrationRewrite } from "./types.js";
19
+
20
+ /** One rewrite, with everything a diagnostic could be routed by.
21
+ *
22
+ * A diagnostic carries at most two routing facts — the file it is in and the
23
+ * resource it is about — and it routinely carries only one. So a record keeps
24
+ * both, and both indexes below are built over the same records. */
25
+ interface RewriteRecord {
26
+ readonly source: string;
27
+ readonly kind?: string;
28
+ readonly name?: string;
29
+ readonly rewrite: MigrationRewrite;
30
+ }
31
+
32
+ /** Records reachable by each routing fact.
33
+ *
34
+ * `byFile` is the general index: every rewrite is in it, so a diagnostic that
35
+ * names only its file is still remapped. Indexing by identity ALONE was the
36
+ * hole this closes — a diagnostic with no `data.resource` (a module-level one)
37
+ * and a rewrite in a document with no `metadata.name` (every `Telo.Import`)
38
+ * were both simply unreachable.
39
+ *
40
+ * `byIdentity` is the NARROWING index, not a substitute: `(kind, name)` is not
41
+ * an identity across a graph, since resource names are module-scoped and two
42
+ * modules routinely declare the same one (`Store`, `Connection`, …). Applying
43
+ * one module's record to another's diagnostic is exactly how a squiggle lands
44
+ * on an unrelated node — the failure this pass exists to prevent. */
45
+ interface RewriteIndex {
46
+ readonly byFile: Map<string, RewriteRecord[]>;
47
+ readonly byIdentity: Map<string, RewriteRecord[]>;
48
+ readonly size: number;
49
+ }
50
+
51
+ function identityKey(kind: string, name: string): string {
52
+ return `${kind}\0${name}`;
53
+ }
54
+
55
+ function push(index: Map<string, RewriteRecord[]>, key: string, record: RewriteRecord): void {
56
+ const bucket = index.get(key);
57
+ if (bucket) bucket.push(record);
58
+ else index.set(key, [record]);
59
+ }
60
+
61
+ function buildIndex(graph: LoadedGraph): RewriteIndex {
62
+ const byFile = new Map<string, RewriteRecord[]>();
63
+ const byIdentity = new Map<string, RewriteRecord[]>();
64
+ let size = 0;
65
+ for (const mod of graph.modules.values()) {
66
+ for (const file of [mod.owner, ...mod.partials]) {
67
+ for (const rewrite of file.migrations.rewrites) {
68
+ const manifest = file.manifests[rewrite.documentIndex];
69
+ const kind = typeof manifest?.kind === "string" ? manifest.kind : undefined;
70
+ const name = typeof manifest?.metadata?.name === "string" ? manifest.metadata.name : undefined;
71
+ const record: RewriteRecord = { source: file.source, kind, name, rewrite };
72
+ push(byFile, file.source, record);
73
+ if (kind !== undefined && name !== undefined) {
74
+ push(byIdentity, identityKey(kind, name), record);
75
+ }
76
+ size++;
77
+ }
78
+ }
79
+ }
80
+ return { byFile, byIdentity, size };
81
+ }
82
+
83
+ /** The records a diagnostic may be remapped against, narrowed by whichever
84
+ * routing facts it carries. `undefined` means "cannot be narrowed to one
85
+ * file", the one case where no answer is better than a guess. */
86
+ function candidatesFor(
87
+ index: RewriteIndex,
88
+ filePath: string | undefined,
89
+ kind: string | undefined,
90
+ name: string | undefined,
91
+ ): RewriteRecord[] | undefined {
92
+ if (kind !== undefined && name !== undefined) {
93
+ const records = index.byIdentity.get(identityKey(kind, name));
94
+ if (!records) return undefined;
95
+ if (filePath !== undefined) return records.filter((r) => r.source === filePath);
96
+ // Several files declare this identity and the diagnostic names none of
97
+ // them. Guessing would move a squiggle onto an unrelated node, which is
98
+ // worse than the parent-squiggle fallback leaving it where it is.
99
+ return new Set(records.map((r) => r.source)).size > 1 ? undefined : records;
100
+ }
101
+ if (filePath !== undefined) return index.byFile.get(filePath);
102
+ return undefined;
103
+ }
104
+
105
+ /** `path` with the longest matching migrated prefix swapped back to the legacy
106
+ * spelling, or `undefined` when no rewrite touched it. Longest-prefix wins so
107
+ * a rewrite nested inside another resolves against the innermost one.
108
+ *
109
+ * Two records covering the same path with DIFFERENT legacy spellings are
110
+ * ambiguous — a file-scoped candidate set spans every document in the file,
111
+ * and two documents can share a path. Refusing beats picking one. */
112
+ function remapPath(path: string, records: readonly RewriteRecord[]): string | undefined {
113
+ let best: MigrationRewrite | undefined;
114
+ let ambiguous = false;
115
+ for (const { rewrite } of records) {
116
+ const migrated = rewrite.migratedPath;
117
+ if (migrated === rewrite.legacyPath) continue;
118
+ const covers =
119
+ path === migrated || path.startsWith(`${migrated}.`) || path.startsWith(`${migrated}[`);
120
+ if (!covers) continue;
121
+ if (!best || migrated.length > best.migratedPath.length) {
122
+ best = rewrite;
123
+ ambiguous = false;
124
+ } else if (
125
+ migrated.length === best.migratedPath.length &&
126
+ rewrite.legacyPath !== best.legacyPath
127
+ ) {
128
+ ambiguous = true;
129
+ }
130
+ }
131
+ if (!best || ambiguous) return undefined;
132
+ return best.legacyPath + path.slice(best.migratedPath.length);
133
+ }
134
+
135
+ /**
136
+ * Rewrite every diagnostic's `data.path` from the migrated spelling back to
137
+ * what the author wrote, so position lookups against the raw file resolve.
138
+ *
139
+ * A no-op — and returns the input array — when nothing in the graph was
140
+ * migrated, which is the overwhelmingly common case.
141
+ */
142
+ export function remapMigratedPaths(
143
+ graph: LoadedGraph,
144
+ diagnostics: readonly AnalysisDiagnostic[],
145
+ ): AnalysisDiagnostic[] {
146
+ const index = buildIndex(graph);
147
+ if (index.size === 0) return [...diagnostics];
148
+
149
+ return diagnostics.map((d) => {
150
+ const data = d.data as
151
+ | { resource?: { kind?: string; name?: string }; path?: string; filePath?: string }
152
+ | undefined;
153
+ if (typeof data?.path !== "string") return d;
154
+
155
+ const records = candidatesFor(
156
+ index,
157
+ typeof data.filePath === "string" ? data.filePath : undefined,
158
+ typeof data.resource?.kind === "string" ? data.resource.kind : undefined,
159
+ typeof data.resource?.name === "string" ? data.resource.name : undefined,
160
+ );
161
+ if (!records || records.length === 0) return d;
162
+
163
+ const remapped = remapPath(data.path, records);
164
+ if (remapped === undefined || remapped === data.path) return d;
165
+ return { ...d, data: { ...data, path: remapped } };
166
+ });
167
+ }
@@ -0,0 +1,40 @@
1
+ /** The core migration set — entries that ship with the analyzer and may match
2
+ * any node.
3
+ *
4
+ * **The set is data, not code.** One JSON file per entry, canonical at
5
+ * `analyzer/migrations/`, read as one lexically ordered list: adding a
6
+ * migration is one file, and retiring one is deleting it. Nothing here lists
7
+ * the entries — TypeScript has no glob import, so the barrel beside the copies
8
+ * is emitted from the same directory listing that produced them. A
9
+ * hand-maintained list was the one place this mechanism could fail silently:
10
+ * add a file, forget the import, and the migration never fires, which looks
11
+ * exactly like one that ran.
12
+ *
13
+ * The files live beside the language implementations rather than inside any
14
+ * one of them because every kernel must apply the IDENTICAL rewrite — a
15
+ * rewrite added to one side would mean one artifact means two things on two
16
+ * kernels, invisibly, since a migration that succeeds is silent. Only
17
+ * `analyzer/nodejs` reads them today — the Rust reader is planned, and until
18
+ * it lands the Rust kernel applies NO migration, so a legacy spelling it
19
+ * cannot otherwise interpret fails there rather than being rewritten. When it
20
+ * lands it will embed these files with `include_str!`; a Go half would use
21
+ * `//go:embed`. JSON rather than YAML for one reason: it is the only format
22
+ * all three embed with no generation step, because TypeScript's only native
23
+ * embed is `resolveJsonModule`. The copy under `./entries/` is made by the
24
+ * analyzer's `prepare` (`scripts/copy-migration-entries.mjs`) — identical
25
+ * bytes, so no entry's MEANING is ever derived from anything.
26
+ *
27
+ * **Entries carry no version stamp.** "Can this be deleted?" turns on whether
28
+ * any published artifact still carries the legacy spelling, which the
29
+ * artifact's own release version cannot answer — the hub can, since it caches
30
+ * every tracked module version's `telo.yaml`. A stamp would record when an
31
+ * entry was written, which git already does, while looking like an answer to a
32
+ * question it does not address. */
33
+
34
+ import { parseMigrationEntry } from "./entry-data.js";
35
+ import { MIGRATION_ENTRY_FILES } from "./entries/index.js";
36
+ import type { MigrationEntry } from "./types.js";
37
+
38
+ export const CORE_MIGRATIONS: readonly MigrationEntry[] = MIGRATION_ENTRY_FILES.map(
39
+ ([file, data]) => parseMigrationEntry(file, data),
40
+ );
@@ -0,0 +1,169 @@
1
+ /** How a rewrite is REPORTED — the provenance record and the diagnostic.
2
+ *
3
+ * Split from the driver because the two answer different questions: the driver
4
+ * owns the guarantees (one pass, a frozen match set, all-or-nothing patches,
5
+ * refusal rather than a guess), while everything here is about telling an
6
+ * author what happened. Nothing in this file can change what a migration does.
7
+ *
8
+ * **A diagnostic composes in three parts and the entry writes only one.** What
9
+ * changed and how to apply it are generated identically for every entry, from
10
+ * the matched key and value, the replacement, and the operation verbs. What
11
+ * the driver cannot know is *why*, which is the part that makes a deprecation
12
+ * actionable rather than mysterious — so an entry supplies `reason`, and never
13
+ * a clause of the generated sentence. */
14
+
15
+ import type { ResourceManifest } from "@telorun/sdk";
16
+ import { isTaggedSentinel } from "@telorun/templating";
17
+ import { formatMigrationPath, type MigrationEffect, type PatchPlan } from "./patch.js";
18
+ import type {
19
+ MigrationEntry,
20
+ MigrationOperation,
21
+ MigrationPath,
22
+ MigrationRewrite,
23
+ } from "./types.js";
24
+ import type { AnalysisDiagnostic, DiagnosticFix } from "../types.js";
25
+
26
+ /** One patch that applied, as the reporting side needs to see it. */
27
+ export interface AppliedPatch {
28
+ readonly entry: MigrationEntry;
29
+ readonly documentIndex: number;
30
+ readonly matched: MigrationPath;
31
+ readonly plan: PatchPlan;
32
+ readonly ops: readonly MigrationOperation[];
33
+ }
34
+
35
+ export function toRewrite(applied: AppliedPatch): MigrationRewrite {
36
+ return {
37
+ entryId: applied.entry.id,
38
+ code: applied.entry.code,
39
+ severity: applied.entry.severity,
40
+ documentIndex: applied.documentIndex,
41
+ legacyPath: formatMigrationPath(applied.matched),
42
+ migratedPath: formatMigrationPath(applied.plan.finalPath),
43
+ summary: describeChange(applied),
44
+ };
45
+ }
46
+
47
+ export function toDiagnostic(
48
+ applied: AppliedPatch,
49
+ rewrite: MigrationRewrite,
50
+ source: string,
51
+ manifests: ReadonlyArray<ResourceManifest | null>,
52
+ ): AnalysisDiagnostic {
53
+ const fix = deriveFix(applied);
54
+ const closing = fix
55
+ ? "Run `telo migrate` to apply it."
56
+ : `no quick fix (${refusalPhrase(applied.ops)}) — run \`telo migrate\``;
57
+
58
+ const manifest = manifests[applied.documentIndex];
59
+ const kind = manifest?.kind;
60
+ const name = manifest?.metadata?.name;
61
+
62
+ return {
63
+ severity: applied.entry.severity,
64
+ code: applied.entry.code,
65
+ source: "telo-analyzer",
66
+ message: `${rewrite.summary}\n${applied.entry.reason}\n${closing}`,
67
+ data: {
68
+ filePath: source,
69
+ // The AUTHOR's path, not the post-rewrite one: the position index is
70
+ // built from the raw file and knows only the spelling that is in it.
71
+ path: rewrite.legacyPath,
72
+ ...(typeof kind === "string" && typeof name === "string"
73
+ ? { resource: { kind, name } }
74
+ : {}),
75
+ ...(fix ? { fix } : {}),
76
+ migration: {
77
+ id: applied.entry.id,
78
+ legacyPath: rewrite.legacyPath,
79
+ migratedPath: rewrite.migratedPath,
80
+ },
81
+ },
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Whether a quick fix exists is DERIVED from the operations, never declared.
87
+ *
88
+ * `DiagnosticFix` is a whole-value `replacement` written over a value node's
89
+ * span, and it promises a repair applicable without review. A lone `set-value`
90
+ * producing a scalar is exactly that. Anything else — a key rename, a tag, a
91
+ * collection edit, a structured value — has no honest whole-value form, and the
92
+ * diagnostic says so rather than offering a repair that would corrupt the file.
93
+ * The derivation is total, so a migration never silently lacks one.
94
+ */
95
+ function deriveFix(applied: AppliedPatch): DiagnosticFix | undefined {
96
+ if (applied.ops.length !== 1) return undefined;
97
+ const only = applied.ops[0]!;
98
+ if (only.op !== "set-value") return undefined;
99
+ const value = applied.plan.after;
100
+ if (typeof value === "string") return { replacement: value };
101
+ if (typeof value === "number" || typeof value === "boolean" || value === null) {
102
+ return { replacement: String(value) };
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function refusalPhrase(ops: readonly MigrationOperation[]): string {
108
+ for (const op of ops) {
109
+ switch (op.op) {
110
+ case "rename-key":
111
+ return "renames a key";
112
+ case "set-tag":
113
+ return "adds a tag";
114
+ case "insert-item":
115
+ return "inserts an item";
116
+ case "remove-entry":
117
+ return "removes an entry";
118
+ default:
119
+ break;
120
+ }
121
+ }
122
+ return "writes a structured value";
123
+ }
124
+
125
+ /** Only the items an `insert-item` added, narrowed by the effect's own kind so
126
+ * the value is read off a typed field rather than cast out of a union. */
127
+ function insertedValues(effects: readonly MigrationEffect[]): unknown[] | undefined {
128
+ const values: unknown[] = [];
129
+ for (const effect of effects) {
130
+ if (effect.kind !== "insert-item") return undefined;
131
+ values.push(effect.value);
132
+ }
133
+ return values.length > 0 ? values : undefined;
134
+ }
135
+
136
+ /** The generated "what changed" sentence — the half no entry writes. */
137
+ function describeChange(applied: AppliedPatch): string {
138
+ const legacyKey = lastSegment(applied.matched);
139
+ const migratedKey = lastSegment(applied.plan.finalPath);
140
+ const { before, after, effects } = applied.plan;
141
+
142
+ if (after === undefined && effects.some((e) => e.kind === "remove-entry")) {
143
+ return `\`${legacyKey}\` is no longer used.`;
144
+ }
145
+ const inserted = insertedValues(effects);
146
+ if (inserted) {
147
+ return `\`${formatMigrationPath(applied.matched)}\` gains \`${inserted
148
+ .map(renderValue)
149
+ .join("`, `")}\`.`;
150
+ }
151
+ return `\`${legacyKey}: ${renderValue(before)}\` is now written \`${migratedKey}: ${renderValue(after)}\`.`;
152
+ }
153
+
154
+ function lastSegment(path: MigrationPath): string {
155
+ const last = path[path.length - 1];
156
+ return typeof last === "number" ? `[${last}]` : String(last ?? "");
157
+ }
158
+
159
+ function renderValue(value: unknown): string {
160
+ if (typeof value === "string") return value;
161
+ if (value === null || value === undefined) return "null";
162
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
163
+ if (isTaggedSentinel(value)) return `!${value.engine} ${value.source}`;
164
+ try {
165
+ return JSON.stringify(value) ?? String(value);
166
+ } catch {
167
+ return String(value);
168
+ }
169
+ }
@@ -0,0 +1,144 @@
1
+ /** Manifest migrations — the entry vocabulary.
2
+ *
3
+ * A migration is a MATCHER plus a PATCH. The two halves are at different
4
+ * maturities and that is deliberate (`plans/manifest-migrations.md`):
5
+ *
6
+ * - The **patch** names what it targets. Every operation has a known YAML edit
7
+ * form, which is what makes a migration applicable to a *file* at all and
8
+ * what lets the driver derive whether a quick fix exists — read straight off
9
+ * the verb, never declared by the author.
10
+ * - The **matcher** selects every occurrence of a legacy spelling, which is
11
+ * the half a plain patch format has none of (see `match.ts`).
12
+ *
13
+ * BOTH halves are data, and an entry contains no code at all. That is what
14
+ * lets one entry file be read by every kernel: a predicate expressed in one
15
+ * language would mean one artifact is read two ways, invisibly, since a
16
+ * migration that succeeds is silent. A migration that does not fit is a signal
17
+ * to extend the vocabulary, never to hand-write a rewrite. */
18
+
19
+ import type { DiagnosticSeverity } from "../types.js";
20
+ import type { MigrationMatch } from "./match.js";
21
+
22
+ /** A location inside one manifest document. Segments are mapping keys
23
+ * (strings) and sequence indices (numbers) — the same shape `Document.getIn`
24
+ * takes, so the tree applier and the YAML applier address a node identically. */
25
+ export type MigrationPath = ReadonlyArray<string | number>;
26
+
27
+ /** Rename the matched mapping entry's KEY, within its own mapping. Deliberately
28
+ * not JSON Patch's `move`, which relocates a value anywhere in a document
29
+ * across parents and replaces an occupied destination. This refuses an
30
+ * occupied destination instead — silently discarding a value the author wrote
31
+ * is exactly what the leave-it-alone invariant exists to prevent. */
32
+ export interface RenameKeyOperation {
33
+ readonly op: "rename-key";
34
+ readonly to: string;
35
+ }
36
+
37
+ /** Replace the value at the matched location.
38
+ *
39
+ * Exactly one of `value` / `qualify` is supplied. `qualify` prefixes the
40
+ * existing string — the shape a spelling rewrite that alias-qualifies a bare
41
+ * name needs, which a literal `value` cannot express because a patch is static
42
+ * data and cannot read the match. */
43
+ export interface SetValueOperation {
44
+ readonly op: "set-value";
45
+ readonly value?: unknown;
46
+ readonly qualify?: string;
47
+ }
48
+
49
+ /** Put the matched scalar behind a templating tag (`!cel`, `!ref`, …). `tag` is
50
+ * the engine name without its `!`. */
51
+ export interface SetTagOperation {
52
+ readonly op: "set-tag";
53
+ readonly tag: string;
54
+ }
55
+
56
+ /** Insert an item into the matched sequence. `at` defaults to the end. */
57
+ export interface InsertItemOperation {
58
+ readonly op: "insert-item";
59
+ readonly value: unknown;
60
+ readonly at?: number;
61
+ }
62
+
63
+ /** Remove the matched mapping entry or sequence item. */
64
+ export interface RemoveEntryOperation {
65
+ readonly op: "remove-entry";
66
+ }
67
+
68
+ /** The closed operation vocabulary. Named for what each TARGETS, so the target
69
+ * is never inferred from which parameter happens to be present and the
70
+ * quick-fix question reads off the name. */
71
+ export type MigrationOperation =
72
+ | RenameKeyOperation
73
+ | SetValueOperation
74
+ | SetTagOperation
75
+ | InsertItemOperation
76
+ | RemoveEntryOperation;
77
+
78
+ export const MIGRATION_OPS = [
79
+ "rename-key",
80
+ "set-value",
81
+ "set-tag",
82
+ "insert-item",
83
+ "remove-entry",
84
+ ] as const;
85
+
86
+ /** One legacy spelling and the edit that replaces it. */
87
+ export interface MigrationRule {
88
+ /** Which nodes this rule rewrites, resolved against the frozen pre-migration
89
+ * tree. Declarative — see `match.ts`. */
90
+ readonly match: MigrationMatch;
91
+ /** Applied in order at each match. A patch that cannot apply in full leaves
92
+ * the node untouched. */
93
+ readonly patch: readonly MigrationOperation[];
94
+ }
95
+
96
+ /** One deprecation story. May carry several rules — the value-type unification
97
+ * changed three spellings but tells the author one thing, which is why the
98
+ * rationale is entry-level and a mechanical description is not. */
99
+ export interface MigrationEntry {
100
+ /** Stable identifier. Names which migration fired; docs list them. */
101
+ readonly id: string;
102
+ /** Diagnostic code reported for every rewrite this entry makes. */
103
+ readonly code: string;
104
+ readonly severity: DiagnosticSeverity;
105
+ /** A sentence or two of rationale — the one part the driver cannot generate,
106
+ * and the part that makes a deprecation actionable rather than mysterious.
107
+ * Never a clause of the generated sentence. */
108
+ readonly reason: string;
109
+ readonly rules: readonly MigrationRule[];
110
+ }
111
+
112
+ /** One applied rewrite. Path provenance is part of the driver's contract:
113
+ * diagnostics are remapped through `legacyPath` before position resolution,
114
+ * and `telo migrate` reads its edit target from the same record — the location
115
+ * in the author's file, not the post-rewrite path. */
116
+ export interface MigrationRewrite {
117
+ readonly entryId: string;
118
+ readonly code: string;
119
+ readonly severity: DiagnosticSeverity;
120
+ readonly documentIndex: number;
121
+ /** Dotted path (`a.b[0].c`) as the AUTHOR wrote it — the key into a
122
+ * position index built from the raw file. */
123
+ readonly legacyPath: string;
124
+ /** Dotted path after the rewrite. Equal to `legacyPath` unless a
125
+ * `rename-key` moved it. */
126
+ readonly migratedPath: string;
127
+ /** Human-readable description of what changed, generated by the driver. */
128
+ readonly summary: string;
129
+ }
130
+
131
+ /** Why a matched patch was refused. A migration that cannot rewrite leaves the
132
+ * node untouched for the ordinary validator to reject — never guessing, never
133
+ * dropping. */
134
+ export type MigrationRefusal =
135
+ | "path-not-found"
136
+ | "destination-occupied"
137
+ | "not-a-mapping-entry"
138
+ | "not-a-sequence"
139
+ | "not-a-scalar"
140
+ | "malformed-value"
141
+ /** The value is already what the patch would write. A rule should match only
142
+ * the legacy spelling, so this means its matcher was too wide — refusing
143
+ * keeps that from surfacing as a deprecation the author cannot act on. */
144
+ | "nothing-to-rewrite";