@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.
- package/dist/analyzer.d.ts +5 -0
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +232 -94
- package/dist/cel-environment.d.ts.map +1 -1
- package/dist/cel-environment.js +6 -3
- package/dist/definition-registry.d.ts +5 -0
- package/dist/definition-registry.d.ts.map +1 -1
- package/dist/definition-registry.js +9 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/invocation-contract.d.ts +21 -13
- package/dist/invocation-contract.d.ts.map +1 -1
- package/dist/invocation-contract.js +43 -46
- package/dist/loaded-types.d.ts +13 -0
- package/dist/loaded-types.d.ts.map +1 -1
- package/dist/manifest-loader.d.ts +8 -1
- package/dist/manifest-loader.d.ts.map +1 -1
- package/dist/manifest-loader.js +33 -6
- package/dist/manifest-visitor.d.ts +4 -0
- package/dist/manifest-visitor.d.ts.map +1 -1
- package/dist/manifest-visitor.js +3 -3
- package/dist/migrations/driver.d.ts +69 -0
- package/dist/migrations/driver.d.ts.map +1 -0
- package/dist/migrations/driver.js +200 -0
- package/dist/migrations/entries/index.d.ts +3 -0
- package/dist/migrations/entries/index.d.ts.map +1 -0
- package/dist/migrations/entries/index.js +9 -0
- package/dist/migrations/entries/normalize-value-types.json +48 -0
- package/dist/migrations/entries/ref-slot-scalar-type.json +20 -0
- package/dist/migrations/entry-data.d.ts +25 -0
- package/dist/migrations/entry-data.d.ts.map +1 -0
- package/dist/migrations/entry-data.js +171 -0
- package/dist/migrations/index.d.ts +22 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +19 -0
- package/dist/migrations/match.d.ts +162 -0
- package/dist/migrations/match.d.ts.map +1 -0
- package/dist/migrations/match.js +295 -0
- package/dist/migrations/patch.d.ts +68 -0
- package/dist/migrations/patch.d.ts.map +1 -0
- package/dist/migrations/patch.js +236 -0
- package/dist/migrations/provenance.d.ts +25 -0
- package/dist/migrations/provenance.d.ts.map +1 -0
- package/dist/migrations/provenance.js +118 -0
- package/dist/migrations/registry.d.ts +35 -0
- package/dist/migrations/registry.d.ts.map +1 -0
- package/dist/migrations/registry.js +35 -0
- package/dist/migrations/report.d.ts +28 -0
- package/dist/migrations/report.d.ts.map +1 -0
- package/dist/migrations/report.js +144 -0
- package/dist/migrations/types.d.ts +115 -0
- package/dist/migrations/types.d.ts.map +1 -0
- package/dist/migrations/types.js +24 -0
- package/dist/migrations/yaml-edit.d.ts +21 -0
- package/dist/migrations/yaml-edit.d.ts.map +1 -0
- package/dist/migrations/yaml-edit.js +245 -0
- package/dist/module-file-claims.d.ts +65 -0
- package/dist/module-file-claims.d.ts.map +1 -0
- package/dist/module-file-claims.js +106 -0
- package/dist/parse-loaded-file.d.ts +8 -0
- package/dist/parse-loaded-file.d.ts.map +1 -1
- package/dist/parse-loaded-file.js +22 -13
- package/dist/resolve-schema-type-refs.d.ts +29 -12
- package/dist/resolve-schema-type-refs.d.ts.map +1 -1
- package/dist/resolve-schema-type-refs.js +89 -16
- package/dist/schema-compat.d.ts +35 -9
- package/dist/schema-compat.d.ts.map +1 -1
- package/dist/schema-compat.js +181 -54
- package/dist/schema-region.d.ts +33 -0
- package/dist/schema-region.d.ts.map +1 -0
- package/dist/schema-region.js +45 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +6 -0
- package/dist/validate-cel-context.d.ts.map +1 -1
- package/dist/validate-cel-context.js +29 -3
- package/dist/validate-include-placement.d.ts +26 -0
- package/dist/validate-include-placement.d.ts.map +1 -0
- package/dist/validate-include-placement.js +67 -0
- package/dist/validate-step-inputs.d.ts +8 -1
- package/dist/validate-step-inputs.d.ts.map +1 -1
- package/dist/validate-step-inputs.js +47 -2
- package/dist/validate-throws-coverage.d.ts.map +1 -1
- package/dist/validate-throws-coverage.js +15 -12
- package/dist/validate-value-type-slots.d.ts +43 -0
- package/dist/validate-value-type-slots.d.ts.map +1 -0
- package/dist/validate-value-type-slots.js +142 -0
- package/dist/value-type-keyword.d.ts +60 -0
- package/dist/value-type-keyword.d.ts.map +1 -0
- package/dist/value-type-keyword.js +119 -0
- package/dist/yaml-source-edit.d.ts +56 -0
- package/dist/yaml-source-edit.d.ts.map +1 -0
- package/dist/yaml-source-edit.js +93 -0
- package/package.json +4 -3
- package/src/analyzer.ts +276 -129
- package/src/cel-environment.ts +6 -3
- package/src/definition-registry.ts +10 -0
- package/src/index.ts +31 -3
- package/src/invocation-contract.ts +43 -48
- package/src/loaded-types.ts +13 -0
- package/src/manifest-loader.ts +37 -6
- package/src/manifest-visitor.ts +11 -3
- package/src/migrations/driver.ts +262 -0
- package/src/migrations/entries/index.ts +10 -0
- package/src/migrations/entries/normalize-value-types.json +48 -0
- package/src/migrations/entries/ref-slot-scalar-type.json +20 -0
- package/src/migrations/entry-data.ts +207 -0
- package/src/migrations/index.ts +22 -0
- package/src/migrations/match.ts +382 -0
- package/src/migrations/patch.ts +265 -0
- package/src/migrations/provenance.ts +167 -0
- package/src/migrations/registry.ts +40 -0
- package/src/migrations/report.ts +169 -0
- package/src/migrations/types.ts +144 -0
- package/src/migrations/yaml-edit.ts +260 -0
- package/src/module-file-claims.ts +168 -0
- package/src/parse-loaded-file.ts +31 -12
- package/src/resolve-schema-type-refs.ts +93 -16
- package/src/schema-compat.ts +218 -62
- package/src/schema-region.ts +46 -0
- package/src/types.ts +54 -0
- package/src/validate-cel-context.ts +36 -3
- package/src/validate-include-placement.ts +70 -0
- package/src/validate-step-inputs.ts +56 -1
- package/src/validate-throws-coverage.ts +16 -11
- package/src/validate-value-type-slots.ts +182 -0
- package/src/value-type-keyword.ts +135 -0
- package/src/yaml-source-edit.ts +106 -0
- package/dist/binary-slot.d.ts +0 -39
- package/dist/binary-slot.d.ts.map +0 -1
- package/dist/binary-slot.js +0 -58
- package/src/binary-slot.ts +0 -71
|
@@ -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"}
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
function identityKey(kind, name) {
|
|
16
|
+
return `${kind}\0${name}`;
|
|
17
|
+
}
|
|
18
|
+
function push(index, key, record) {
|
|
19
|
+
const bucket = index.get(key);
|
|
20
|
+
if (bucket)
|
|
21
|
+
bucket.push(record);
|
|
22
|
+
else
|
|
23
|
+
index.set(key, [record]);
|
|
24
|
+
}
|
|
25
|
+
function buildIndex(graph) {
|
|
26
|
+
const byFile = new Map();
|
|
27
|
+
const byIdentity = new Map();
|
|
28
|
+
let size = 0;
|
|
29
|
+
for (const mod of graph.modules.values()) {
|
|
30
|
+
for (const file of [mod.owner, ...mod.partials]) {
|
|
31
|
+
for (const rewrite of file.migrations.rewrites) {
|
|
32
|
+
const manifest = file.manifests[rewrite.documentIndex];
|
|
33
|
+
const kind = typeof manifest?.kind === "string" ? manifest.kind : undefined;
|
|
34
|
+
const name = typeof manifest?.metadata?.name === "string" ? manifest.metadata.name : undefined;
|
|
35
|
+
const record = { source: file.source, kind, name, rewrite };
|
|
36
|
+
push(byFile, file.source, record);
|
|
37
|
+
if (kind !== undefined && name !== undefined) {
|
|
38
|
+
push(byIdentity, identityKey(kind, name), record);
|
|
39
|
+
}
|
|
40
|
+
size++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { byFile, byIdentity, size };
|
|
45
|
+
}
|
|
46
|
+
/** The records a diagnostic may be remapped against, narrowed by whichever
|
|
47
|
+
* routing facts it carries. `undefined` means "cannot be narrowed to one
|
|
48
|
+
* file", the one case where no answer is better than a guess. */
|
|
49
|
+
function candidatesFor(index, filePath, kind, name) {
|
|
50
|
+
if (kind !== undefined && name !== undefined) {
|
|
51
|
+
const records = index.byIdentity.get(identityKey(kind, name));
|
|
52
|
+
if (!records)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (filePath !== undefined)
|
|
55
|
+
return records.filter((r) => r.source === filePath);
|
|
56
|
+
// Several files declare this identity and the diagnostic names none of
|
|
57
|
+
// them. Guessing would move a squiggle onto an unrelated node, which is
|
|
58
|
+
// worse than the parent-squiggle fallback leaving it where it is.
|
|
59
|
+
return new Set(records.map((r) => r.source)).size > 1 ? undefined : records;
|
|
60
|
+
}
|
|
61
|
+
if (filePath !== undefined)
|
|
62
|
+
return index.byFile.get(filePath);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
/** `path` with the longest matching migrated prefix swapped back to the legacy
|
|
66
|
+
* spelling, or `undefined` when no rewrite touched it. Longest-prefix wins so
|
|
67
|
+
* a rewrite nested inside another resolves against the innermost one.
|
|
68
|
+
*
|
|
69
|
+
* Two records covering the same path with DIFFERENT legacy spellings are
|
|
70
|
+
* ambiguous — a file-scoped candidate set spans every document in the file,
|
|
71
|
+
* and two documents can share a path. Refusing beats picking one. */
|
|
72
|
+
function remapPath(path, records) {
|
|
73
|
+
let best;
|
|
74
|
+
let ambiguous = false;
|
|
75
|
+
for (const { rewrite } of records) {
|
|
76
|
+
const migrated = rewrite.migratedPath;
|
|
77
|
+
if (migrated === rewrite.legacyPath)
|
|
78
|
+
continue;
|
|
79
|
+
const covers = path === migrated || path.startsWith(`${migrated}.`) || path.startsWith(`${migrated}[`);
|
|
80
|
+
if (!covers)
|
|
81
|
+
continue;
|
|
82
|
+
if (!best || migrated.length > best.migratedPath.length) {
|
|
83
|
+
best = rewrite;
|
|
84
|
+
ambiguous = false;
|
|
85
|
+
}
|
|
86
|
+
else if (migrated.length === best.migratedPath.length &&
|
|
87
|
+
rewrite.legacyPath !== best.legacyPath) {
|
|
88
|
+
ambiguous = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!best || ambiguous)
|
|
92
|
+
return undefined;
|
|
93
|
+
return best.legacyPath + path.slice(best.migratedPath.length);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Rewrite every diagnostic's `data.path` from the migrated spelling back to
|
|
97
|
+
* what the author wrote, so position lookups against the raw file resolve.
|
|
98
|
+
*
|
|
99
|
+
* A no-op — and returns the input array — when nothing in the graph was
|
|
100
|
+
* migrated, which is the overwhelmingly common case.
|
|
101
|
+
*/
|
|
102
|
+
export function remapMigratedPaths(graph, diagnostics) {
|
|
103
|
+
const index = buildIndex(graph);
|
|
104
|
+
if (index.size === 0)
|
|
105
|
+
return [...diagnostics];
|
|
106
|
+
return diagnostics.map((d) => {
|
|
107
|
+
const data = d.data;
|
|
108
|
+
if (typeof data?.path !== "string")
|
|
109
|
+
return d;
|
|
110
|
+
const records = candidatesFor(index, typeof data.filePath === "string" ? data.filePath : undefined, typeof data.resource?.kind === "string" ? data.resource.kind : undefined, typeof data.resource?.name === "string" ? data.resource.name : undefined);
|
|
111
|
+
if (!records || records.length === 0)
|
|
112
|
+
return d;
|
|
113
|
+
const remapped = remapPath(data.path, records);
|
|
114
|
+
if (remapped === undefined || remapped === data.path)
|
|
115
|
+
return d;
|
|
116
|
+
return { ...d, data: { ...data, path: remapped } };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
import type { MigrationEntry } from "./types.js";
|
|
34
|
+
export declare const CORE_MIGRATIONS: readonly MigrationEntry[];
|
|
35
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/migrations/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;oCA+BoC;AAIpC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,eAAO,MAAM,eAAe,EAAE,SAAS,cAAc,EAEpD,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
import { parseMigrationEntry } from "./entry-data.js";
|
|
34
|
+
import { MIGRATION_ENTRY_FILES } from "./entries/index.js";
|
|
35
|
+
export const CORE_MIGRATIONS = MIGRATION_ENTRY_FILES.map(([file, data]) => parseMigrationEntry(file, data));
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
import type { ResourceManifest } from "@telorun/sdk";
|
|
15
|
+
import { type PatchPlan } from "./patch.js";
|
|
16
|
+
import type { MigrationEntry, MigrationOperation, MigrationPath, MigrationRewrite } from "./types.js";
|
|
17
|
+
import type { AnalysisDiagnostic } from "../types.js";
|
|
18
|
+
/** One patch that applied, as the reporting side needs to see it. */
|
|
19
|
+
export interface AppliedPatch {
|
|
20
|
+
readonly entry: MigrationEntry;
|
|
21
|
+
readonly documentIndex: number;
|
|
22
|
+
readonly matched: MigrationPath;
|
|
23
|
+
readonly plan: PatchPlan;
|
|
24
|
+
readonly ops: readonly MigrationOperation[];
|
|
25
|
+
}
|
|
26
|
+
export declare function toRewrite(applied: AppliedPatch): MigrationRewrite;
|
|
27
|
+
export declare function toDiagnostic(applied: AppliedPatch, rewrite: MigrationRewrite, source: string, manifests: ReadonlyArray<ResourceManifest | null>): AnalysisDiagnostic;
|
|
28
|
+
//# sourceMappingURL=report.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../../src/migrations/report.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;0CAY0C;AAE1C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,EAA6C,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AACvF,OAAO,KAAK,EACV,cAAc,EACd,kBAAkB,EAClB,aAAa,EACb,gBAAgB,EACjB,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,kBAAkB,EAAiB,MAAM,aAAa,CAAC;AAErE,qEAAqE;AACrE,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,GAAG,EAAE,SAAS,kBAAkB,EAAE,CAAC;CAC7C;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,YAAY,GAAG,gBAAgB,CAUjE;AAED,wBAAgB,YAAY,CAC1B,OAAO,EAAE,YAAY,EACrB,OAAO,EAAE,gBAAgB,EACzB,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,aAAa,CAAC,gBAAgB,GAAG,IAAI,CAAC,GAChD,kBAAkB,CA+BpB"}
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
import { isTaggedSentinel } from "@telorun/templating";
|
|
15
|
+
import { formatMigrationPath } from "./patch.js";
|
|
16
|
+
export function toRewrite(applied) {
|
|
17
|
+
return {
|
|
18
|
+
entryId: applied.entry.id,
|
|
19
|
+
code: applied.entry.code,
|
|
20
|
+
severity: applied.entry.severity,
|
|
21
|
+
documentIndex: applied.documentIndex,
|
|
22
|
+
legacyPath: formatMigrationPath(applied.matched),
|
|
23
|
+
migratedPath: formatMigrationPath(applied.plan.finalPath),
|
|
24
|
+
summary: describeChange(applied),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function toDiagnostic(applied, rewrite, source, manifests) {
|
|
28
|
+
const fix = deriveFix(applied);
|
|
29
|
+
const closing = fix
|
|
30
|
+
? "Run `telo migrate` to apply it."
|
|
31
|
+
: `no quick fix (${refusalPhrase(applied.ops)}) — run \`telo migrate\``;
|
|
32
|
+
const manifest = manifests[applied.documentIndex];
|
|
33
|
+
const kind = manifest?.kind;
|
|
34
|
+
const name = manifest?.metadata?.name;
|
|
35
|
+
return {
|
|
36
|
+
severity: applied.entry.severity,
|
|
37
|
+
code: applied.entry.code,
|
|
38
|
+
source: "telo-analyzer",
|
|
39
|
+
message: `${rewrite.summary}\n${applied.entry.reason}\n${closing}`,
|
|
40
|
+
data: {
|
|
41
|
+
filePath: source,
|
|
42
|
+
// The AUTHOR's path, not the post-rewrite one: the position index is
|
|
43
|
+
// built from the raw file and knows only the spelling that is in it.
|
|
44
|
+
path: rewrite.legacyPath,
|
|
45
|
+
...(typeof kind === "string" && typeof name === "string"
|
|
46
|
+
? { resource: { kind, name } }
|
|
47
|
+
: {}),
|
|
48
|
+
...(fix ? { fix } : {}),
|
|
49
|
+
migration: {
|
|
50
|
+
id: applied.entry.id,
|
|
51
|
+
legacyPath: rewrite.legacyPath,
|
|
52
|
+
migratedPath: rewrite.migratedPath,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether a quick fix exists is DERIVED from the operations, never declared.
|
|
59
|
+
*
|
|
60
|
+
* `DiagnosticFix` is a whole-value `replacement` written over a value node's
|
|
61
|
+
* span, and it promises a repair applicable without review. A lone `set-value`
|
|
62
|
+
* producing a scalar is exactly that. Anything else — a key rename, a tag, a
|
|
63
|
+
* collection edit, a structured value — has no honest whole-value form, and the
|
|
64
|
+
* diagnostic says so rather than offering a repair that would corrupt the file.
|
|
65
|
+
* The derivation is total, so a migration never silently lacks one.
|
|
66
|
+
*/
|
|
67
|
+
function deriveFix(applied) {
|
|
68
|
+
if (applied.ops.length !== 1)
|
|
69
|
+
return undefined;
|
|
70
|
+
const only = applied.ops[0];
|
|
71
|
+
if (only.op !== "set-value")
|
|
72
|
+
return undefined;
|
|
73
|
+
const value = applied.plan.after;
|
|
74
|
+
if (typeof value === "string")
|
|
75
|
+
return { replacement: value };
|
|
76
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) {
|
|
77
|
+
return { replacement: String(value) };
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
function refusalPhrase(ops) {
|
|
82
|
+
for (const op of ops) {
|
|
83
|
+
switch (op.op) {
|
|
84
|
+
case "rename-key":
|
|
85
|
+
return "renames a key";
|
|
86
|
+
case "set-tag":
|
|
87
|
+
return "adds a tag";
|
|
88
|
+
case "insert-item":
|
|
89
|
+
return "inserts an item";
|
|
90
|
+
case "remove-entry":
|
|
91
|
+
return "removes an entry";
|
|
92
|
+
default:
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return "writes a structured value";
|
|
97
|
+
}
|
|
98
|
+
/** Only the items an `insert-item` added, narrowed by the effect's own kind so
|
|
99
|
+
* the value is read off a typed field rather than cast out of a union. */
|
|
100
|
+
function insertedValues(effects) {
|
|
101
|
+
const values = [];
|
|
102
|
+
for (const effect of effects) {
|
|
103
|
+
if (effect.kind !== "insert-item")
|
|
104
|
+
return undefined;
|
|
105
|
+
values.push(effect.value);
|
|
106
|
+
}
|
|
107
|
+
return values.length > 0 ? values : undefined;
|
|
108
|
+
}
|
|
109
|
+
/** The generated "what changed" sentence — the half no entry writes. */
|
|
110
|
+
function describeChange(applied) {
|
|
111
|
+
const legacyKey = lastSegment(applied.matched);
|
|
112
|
+
const migratedKey = lastSegment(applied.plan.finalPath);
|
|
113
|
+
const { before, after, effects } = applied.plan;
|
|
114
|
+
if (after === undefined && effects.some((e) => e.kind === "remove-entry")) {
|
|
115
|
+
return `\`${legacyKey}\` is no longer used.`;
|
|
116
|
+
}
|
|
117
|
+
const inserted = insertedValues(effects);
|
|
118
|
+
if (inserted) {
|
|
119
|
+
return `\`${formatMigrationPath(applied.matched)}\` gains \`${inserted
|
|
120
|
+
.map(renderValue)
|
|
121
|
+
.join("`, `")}\`.`;
|
|
122
|
+
}
|
|
123
|
+
return `\`${legacyKey}: ${renderValue(before)}\` is now written \`${migratedKey}: ${renderValue(after)}\`.`;
|
|
124
|
+
}
|
|
125
|
+
function lastSegment(path) {
|
|
126
|
+
const last = path[path.length - 1];
|
|
127
|
+
return typeof last === "number" ? `[${last}]` : String(last ?? "");
|
|
128
|
+
}
|
|
129
|
+
function renderValue(value) {
|
|
130
|
+
if (typeof value === "string")
|
|
131
|
+
return value;
|
|
132
|
+
if (value === null || value === undefined)
|
|
133
|
+
return "null";
|
|
134
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
135
|
+
return String(value);
|
|
136
|
+
if (isTaggedSentinel(value))
|
|
137
|
+
return `!${value.engine} ${value.source}`;
|
|
138
|
+
try {
|
|
139
|
+
return JSON.stringify(value) ?? String(value);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return String(value);
|
|
143
|
+
}
|
|
144
|
+
}
|