@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,382 @@
|
|
|
1
|
+
/** The selector half of a migration: what a rule MATCHES, as data.
|
|
2
|
+
*
|
|
3
|
+
* A patch addresses a known location; a migration has to find *every*
|
|
4
|
+
* occurrence of a legacy spelling, so selection is the half a plain patch
|
|
5
|
+
* format has none of. It is declarative for the same reason the operations
|
|
6
|
+
* are: an entry travels to a Rust and a Go kernel, and a predicate expressed
|
|
7
|
+
* in one language would mean one artifact is read two ways — invisibly, since
|
|
8
|
+
* a migration that succeeds is silent.
|
|
9
|
+
*
|
|
10
|
+
* **Containment is POSITIVE and required.** A rule states which document kinds
|
|
11
|
+
* it may touch (`inKind`) and which region of those documents it may reach
|
|
12
|
+
* into (`under`); nothing outside is reachable. The alternative — walk
|
|
13
|
+
* everything and subtract — cannot be made sound, because the set to subtract
|
|
14
|
+
* is unbounded: a `Run.Value` value, an `Assert.Equals` expected, any kind
|
|
15
|
+
* whose config carries a user JSON blob can hold something shaped like the
|
|
16
|
+
* node a rule looks for, and forgetting one corrupts a manifest with no
|
|
17
|
+
* diagnostic. A denylist also cannot express the guarantee the module surface
|
|
18
|
+
* is promised to carry — *a dependency can rename its own field and provably
|
|
19
|
+
* nothing else* — which is a statement about what a rule may reach, so it has
|
|
20
|
+
* to be said positively. `notUnder` remains for subtracting inside a region a
|
|
21
|
+
* rule legitimately reaches, which is a narrowing, not the containment itself.
|
|
22
|
+
*
|
|
23
|
+
* **`under` is ANCHORED at the document root**, not a set of key names to look
|
|
24
|
+
* for anywhere on the path. Anchoring is what makes the containment claim
|
|
25
|
+
* true: a `Telo.Definition`'s `resources:` template body carries other kinds'
|
|
26
|
+
* configuration, and any of it may hold a key spelled `schema` over data that
|
|
27
|
+
* merely looks like a schema — so "some segment of the path is `schema`" would
|
|
28
|
+
* reach the very user JSON blob the positive form exists to keep out, and
|
|
29
|
+
* would delete from it silently. Anchored, `under` names top-level document
|
|
30
|
+
* keys and a region is a genuine subtree.
|
|
31
|
+
*
|
|
32
|
+
* **`inSchema` is the one region a kind list cannot name.** An annotation
|
|
33
|
+
* keyword occurs in author-written JSON Schema, and schema fragments are not
|
|
34
|
+
* confined to kind documents: an inline `inputType:` / `outputType:` on ANY kind
|
|
35
|
+
* that declares one, an API route's `request.schema.body`, a `Telo.JsonSchema`'s
|
|
36
|
+
* `schema`. That set of kinds is open — any kind may declare a schema-valued
|
|
37
|
+
* field — and enumerating the standard library's would put resource-kind
|
|
38
|
+
* knowledge into the analyzer, against the topology-driven constraint. So a rule
|
|
39
|
+
* may instead state that it reaches only inside a SCHEMA REGION, named by the
|
|
40
|
+
* KERNEL's own schema-valued keys, which no kind owns.
|
|
41
|
+
*
|
|
42
|
+
* With `inSchema`, and only with it, `inKind` / `under` may be `["*"]` — and
|
|
43
|
+
* only for a rule keyed on a reserved `x-telo-*` annotation. That pairing IS the
|
|
44
|
+
* containment: the region gate bounds where the walk may go, and the reserved-key
|
|
45
|
+
* rule bounds what it may touch, since an `x-telo-*` key is Telo vocabulary
|
|
46
|
+
* wherever it appears and cannot mean something else in someone's config. Both
|
|
47
|
+
* halves are refused at entry-read time, so a module-shipped entry can no more
|
|
48
|
+
* spell `"*"` than it can name another module's kind.
|
|
49
|
+
*
|
|
50
|
+
* The residue is stated rather than claimed away: a manifest that asserts ABOUT
|
|
51
|
+
* a schema — a schema literal under a key spelled `schema` inside an assertion's
|
|
52
|
+
* expected value — is reachable, and would be rewritten into its own synonym.
|
|
53
|
+
* That cannot be closed in a data-only matcher without naming kinds. It is
|
|
54
|
+
* accepted because the sites the wildcards reach are exactly the ones no
|
|
55
|
+
* enumeration covers, and the alternative leaves an author reading a deprecation
|
|
56
|
+
* `telo migrate` refuses to act on.
|
|
57
|
+
*
|
|
58
|
+
* The vocabulary is closed, which is what makes it a trust boundary once
|
|
59
|
+
* module-shipped entries are aggregated beside core ones. An unrecognized key
|
|
60
|
+
* is refused rather than ignored — a selector that silently matches wider than
|
|
61
|
+
* it reads is the one failure this cannot tolerate. */
|
|
62
|
+
|
|
63
|
+
import { isInSchemaRegion } from "../schema-region.js";
|
|
64
|
+
import type { MigrationPath } from "./types.js";
|
|
65
|
+
|
|
66
|
+
/** The wildcard `inKind` / `under` value. Legal only alongside `inSchema`, and
|
|
67
|
+
* only for a rule keyed on an `x-telo-*` annotation. */
|
|
68
|
+
export const MATCH_ANY = "*";
|
|
69
|
+
|
|
70
|
+
export interface MigrationMatch {
|
|
71
|
+
/** The mapping key this rule rewrites. */
|
|
72
|
+
readonly key: string;
|
|
73
|
+
/** Document `kind:` values this rule may match in. Required and non-empty:
|
|
74
|
+
* a rule that does not say which documents it touches cannot be reasoned
|
|
75
|
+
* about, and is exactly the rule that reaches into a resource's config. */
|
|
76
|
+
readonly inKind: readonly string[];
|
|
77
|
+
/** The region of the document this rule may reach, named by TOP-LEVEL
|
|
78
|
+
* document keys: the matched node must be AT or BELOW one of them. Required
|
|
79
|
+
* and non-empty, for the same reason as `inKind`.
|
|
80
|
+
*
|
|
81
|
+
* Anchored at the root rather than matched anywhere on the path — see the
|
|
82
|
+
* file header; an unanchored `under` reaches into a nested resource's own
|
|
83
|
+
* configuration and is not containment at all.
|
|
84
|
+
*
|
|
85
|
+
* At-or-below rather than strictly-below so the vocabulary is complete — a
|
|
86
|
+
* rule that rewrites a top-level key names that key, instead of the region
|
|
87
|
+
* being unreachable and the grammar needing a second spelling for the
|
|
88
|
+
* document root. */
|
|
89
|
+
readonly under: readonly string[];
|
|
90
|
+
/** The value must deep-equal this. Mutually exclusive with `valueOneOf`. */
|
|
91
|
+
readonly value?: unknown;
|
|
92
|
+
/** The value must be one of these. Matching against the KNOWN legacy values
|
|
93
|
+
* rather than any value is what leaves an unrecognized one alone for the
|
|
94
|
+
* ordinary validator to report, instead of silently rewriting it. */
|
|
95
|
+
readonly valueOneOf?: readonly unknown[];
|
|
96
|
+
/** A key that must be present in the same mapping. The matched key is often
|
|
97
|
+
* stale only *because* of what sits beside it. */
|
|
98
|
+
readonly withSibling?: string;
|
|
99
|
+
/** Ancestor keys that disqualify a match inside the region `under` allows.
|
|
100
|
+
* The data-bearing JSON Schema keywords (`const`, `default`, `enum`,
|
|
101
|
+
* `examples`) hold values that may look like schemas. */
|
|
102
|
+
readonly notUnder?: readonly string[];
|
|
103
|
+
/** Narrow to a JSON Schema region: the match must be at or below a node
|
|
104
|
+
* reached through one of {@link SCHEMA_REGION_KEYS}.
|
|
105
|
+
*
|
|
106
|
+
* A second bound, never a replacement for `inKind` / `under` — it is what
|
|
107
|
+
* makes their wildcard forms safe, and it narrows an enumerated region just as
|
|
108
|
+
* usefully. See the file header for why an annotation rename needs it. */
|
|
109
|
+
readonly inSchema?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const MATCH_KEYS = [
|
|
113
|
+
"key",
|
|
114
|
+
"inKind",
|
|
115
|
+
"under",
|
|
116
|
+
"value",
|
|
117
|
+
"valueOneOf",
|
|
118
|
+
"withSibling",
|
|
119
|
+
"notUnder",
|
|
120
|
+
"inSchema",
|
|
121
|
+
] as const;
|
|
122
|
+
|
|
123
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
124
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Structural equality over JSON values — what `value` / `valueOneOf` compare
|
|
128
|
+
* with, so a match on `true` never also matches `"true"`.
|
|
129
|
+
*
|
|
130
|
+
* Exported because the PATCHER asks the same question in the other direction:
|
|
131
|
+
* "is the value already what this would write". Both answers have to come from
|
|
132
|
+
* one rule, or a rule could match a spelling the patch then declares current. */
|
|
133
|
+
export function deepEquals(a: unknown, b: unknown): boolean {
|
|
134
|
+
if (a === b) return true;
|
|
135
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
136
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
137
|
+
return a.every((item, i) => deepEquals(item, b[i]));
|
|
138
|
+
}
|
|
139
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
140
|
+
const aKeys = Object.keys(a);
|
|
141
|
+
if (aKeys.length !== Object.keys(b).length) return false;
|
|
142
|
+
return aKeys.every((k) => Object.hasOwn(b, k) && deepEquals(a[k], b[k]));
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function requireStringList(describe: string, raw: Record<string, unknown>, key: string): string[] {
|
|
148
|
+
const value = raw[key];
|
|
149
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((v) => typeof v !== "string")) {
|
|
150
|
+
throw new Error(`${describe}: 'match.${key}' must be a non-empty sequence of strings`);
|
|
151
|
+
}
|
|
152
|
+
return value as string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Read a rule's `match` block, refusing anything the vocabulary does not
|
|
156
|
+
* define. `describe` names the entry and rule so a failure says which. */
|
|
157
|
+
export function readMigrationMatch(describe: string, raw: unknown): MigrationMatch {
|
|
158
|
+
if (!isPlainObject(raw)) throw new Error(`${describe}: 'match' must be a mapping`);
|
|
159
|
+
|
|
160
|
+
for (const key of Object.keys(raw)) {
|
|
161
|
+
if (!(MATCH_KEYS as readonly string[]).includes(key)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`${describe}: 'match' has no key '${key}'. Known keys: ${MATCH_KEYS.join(", ")}.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (typeof raw.key !== "string" || raw.key.length === 0) {
|
|
168
|
+
throw new Error(`${describe}: 'match.key' must be a non-empty string`);
|
|
169
|
+
}
|
|
170
|
+
if (Object.hasOwn(raw, "value") && Object.hasOwn(raw, "valueOneOf")) {
|
|
171
|
+
throw new Error(`${describe}: 'match' takes at most one of 'value' or 'valueOneOf'`);
|
|
172
|
+
}
|
|
173
|
+
if (Object.hasOwn(raw, "valueOneOf") && !Array.isArray(raw.valueOneOf)) {
|
|
174
|
+
throw new Error(`${describe}: 'match.valueOneOf' must be a sequence`);
|
|
175
|
+
}
|
|
176
|
+
if (Object.hasOwn(raw, "withSibling") && typeof raw.withSibling !== "string") {
|
|
177
|
+
throw new Error(`${describe}: 'match.withSibling' must be a string`);
|
|
178
|
+
}
|
|
179
|
+
if (
|
|
180
|
+
Object.hasOwn(raw, "notUnder") &&
|
|
181
|
+
(!Array.isArray(raw.notUnder) || raw.notUnder.some((k) => typeof k !== "string"))
|
|
182
|
+
) {
|
|
183
|
+
throw new Error(`${describe}: 'match.notUnder' must be a sequence of strings`);
|
|
184
|
+
}
|
|
185
|
+
if (Object.hasOwn(raw, "inSchema") && typeof raw.inSchema !== "boolean") {
|
|
186
|
+
throw new Error(`${describe}: 'match.inSchema' must be a boolean`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The two conditions that make a wildcard safe, refused here rather than
|
|
190
|
+
// narrowed later: a rule the reader accepts is a rule the driver will run, and
|
|
191
|
+
// a selector that matches wider than it reads is exactly what this vocabulary
|
|
192
|
+
// exists to prevent. A module-shipped entry therefore cannot spell `"*"` at
|
|
193
|
+
// all, because it cannot satisfy both halves for a keyword it does not own.
|
|
194
|
+
const wildcards = [
|
|
195
|
+
...(Array.isArray(raw.inKind) ? (raw.inKind as unknown[]) : []),
|
|
196
|
+
...(Array.isArray(raw.under) ? (raw.under as unknown[]) : []),
|
|
197
|
+
].filter((value) => value === MATCH_ANY);
|
|
198
|
+
if (wildcards.length > 0) {
|
|
199
|
+
if (raw.inSchema !== true) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`${describe}: 'match.inKind' / 'match.under' may only be '${MATCH_ANY}' together with ` +
|
|
202
|
+
`'inSchema: true' — the schema region is what bounds a walk the wildcard unbounds`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (!(raw.key as string).startsWith("x-telo-")) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`${describe}: '${MATCH_ANY}' is legal only for a rule keyed on an 'x-telo-*' annotation, ` +
|
|
208
|
+
`which is Telo vocabulary wherever it appears — '${raw.key}' could mean something else ` +
|
|
209
|
+
`inside a resource's own configuration`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const match: {
|
|
215
|
+
key: string;
|
|
216
|
+
inKind: readonly string[];
|
|
217
|
+
under: readonly string[];
|
|
218
|
+
value?: unknown;
|
|
219
|
+
valueOneOf?: readonly unknown[];
|
|
220
|
+
withSibling?: string;
|
|
221
|
+
notUnder?: readonly string[];
|
|
222
|
+
inSchema?: boolean;
|
|
223
|
+
} = {
|
|
224
|
+
key: raw.key,
|
|
225
|
+
inKind: requireStringList(describe, raw, "inKind"),
|
|
226
|
+
under: requireStringList(describe, raw, "under"),
|
|
227
|
+
};
|
|
228
|
+
if (Object.hasOwn(raw, "value")) match.value = raw.value;
|
|
229
|
+
if (Object.hasOwn(raw, "valueOneOf")) match.valueOneOf = raw.valueOneOf as unknown[];
|
|
230
|
+
if (Object.hasOwn(raw, "withSibling")) match.withSibling = raw.withSibling as string;
|
|
231
|
+
if (Object.hasOwn(raw, "notUnder")) match.notUnder = raw.notUnder as string[];
|
|
232
|
+
if (raw.inSchema === true) match.inSchema = true;
|
|
233
|
+
return match;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** One candidate site: a mapping entry whose key some rule is interested in. */
|
|
237
|
+
interface MatchSite {
|
|
238
|
+
readonly path: MigrationPath;
|
|
239
|
+
readonly value: unknown;
|
|
240
|
+
readonly parent: Record<string, unknown>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Every candidate site in one document, keyed by mapping key.
|
|
244
|
+
*
|
|
245
|
+
* Built ONCE per document and shared by every rule that can apply to it,
|
|
246
|
+
* because the walk is the expensive part and it does not depend on the rule —
|
|
247
|
+
* this is on the kernel's boot path for every file in the graph, so a walk per
|
|
248
|
+
* rule would scale the cost of loading any manifest with the size of the
|
|
249
|
+
* migration set.
|
|
250
|
+
*
|
|
251
|
+
* The walk is bounded by the same containment the rules declare: `roots` is
|
|
252
|
+
* the union of the applicable rules' `under`, so a region no rule can reach is
|
|
253
|
+
* never descended into and a document no rule can match is never walked at
|
|
254
|
+
* all. Only keys some rule asked for allocate a path array. */
|
|
255
|
+
export type MatchIndex = ReadonlyMap<string, readonly MatchSite[]>;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Index `document`'s candidate sites for `keys`, descending only into the
|
|
259
|
+
* top-level regions named by `roots`.
|
|
260
|
+
*
|
|
261
|
+
* The caller has already gated on `inKind` — see `selectMatches`, which repeats
|
|
262
|
+
* the check because it holds the individual rule.
|
|
263
|
+
*/
|
|
264
|
+
export function buildMatchIndex(
|
|
265
|
+
document: unknown,
|
|
266
|
+
keys: ReadonlySet<string>,
|
|
267
|
+
roots: ReadonlySet<string>,
|
|
268
|
+
): MatchIndex {
|
|
269
|
+
const index = new Map<string, MatchSite[]>();
|
|
270
|
+
if (keys.size === 0 || roots.size === 0 || !isPlainObject(document)) return index;
|
|
271
|
+
|
|
272
|
+
// A mutable stack, materialized into an array only when a site is recorded.
|
|
273
|
+
const stack: (string | number)[] = [];
|
|
274
|
+
|
|
275
|
+
const record = (key: string, value: unknown, parent: Record<string, unknown>): void => {
|
|
276
|
+
const bucket = index.get(key) ?? [];
|
|
277
|
+
bucket.push({ path: [...stack], value, parent });
|
|
278
|
+
index.set(key, bucket);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const walk = (node: unknown): void => {
|
|
282
|
+
if (Array.isArray(node)) {
|
|
283
|
+
for (let i = 0; i < node.length; i++) {
|
|
284
|
+
stack.push(i);
|
|
285
|
+
walk(node[i]);
|
|
286
|
+
stack.pop();
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!isPlainObject(node)) return;
|
|
291
|
+
|
|
292
|
+
for (const [key, value] of Object.entries(node)) {
|
|
293
|
+
stack.push(key);
|
|
294
|
+
if (keys.has(key)) record(key, value, node);
|
|
295
|
+
walk(value);
|
|
296
|
+
stack.pop();
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Anchored containment, enforced by where the walk STARTS: everything the
|
|
301
|
+
// index holds is at or below a top-level key some rule named. A wildcard root
|
|
302
|
+
// starts everywhere instead — bounded not by where the walk begins but by the
|
|
303
|
+
// schema region and the reserved key its rule had to declare to earn it.
|
|
304
|
+
const everywhere = roots.has(MATCH_ANY);
|
|
305
|
+
for (const [key, value] of Object.entries(document)) {
|
|
306
|
+
if (!everywhere && !roots.has(key)) continue;
|
|
307
|
+
stack.push(key);
|
|
308
|
+
if (keys.has(key)) record(key, value, document);
|
|
309
|
+
walk(value);
|
|
310
|
+
stack.pop();
|
|
311
|
+
}
|
|
312
|
+
return index;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The rules in `entries` that may match a document of `kind`, with the key and
|
|
316
|
+
* root sets their shared index needs. Empty rules mean the document is not
|
|
317
|
+
* walked at all. */
|
|
318
|
+
export function applicableRules<T extends { readonly match: MigrationMatch }>(
|
|
319
|
+
rules: Iterable<T>,
|
|
320
|
+
kind: unknown,
|
|
321
|
+
): { rules: T[]; keys: Set<string>; roots: Set<string> } {
|
|
322
|
+
const applicable: T[] = [];
|
|
323
|
+
const keys = new Set<string>();
|
|
324
|
+
const roots = new Set<string>();
|
|
325
|
+
if (typeof kind !== "string") return { rules: applicable, keys, roots };
|
|
326
|
+
for (const rule of rules) {
|
|
327
|
+
if (!rule.match.inKind.includes(kind) && !rule.match.inKind.includes(MATCH_ANY)) continue;
|
|
328
|
+
applicable.push(rule);
|
|
329
|
+
keys.add(rule.match.key);
|
|
330
|
+
for (const root of rule.match.under) roots.add(root);
|
|
331
|
+
}
|
|
332
|
+
return { rules: applicable, keys, roots };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function valueMatches(match: MigrationMatch, value: unknown): boolean {
|
|
336
|
+
if (Object.hasOwn(match, "value")) return deepEquals(match.value, value);
|
|
337
|
+
if (match.valueOneOf) return match.valueOneOf.some((candidate) => deepEquals(candidate, value));
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The sites in `index` this match selects, in document order. */
|
|
342
|
+
export function selectMatches(
|
|
343
|
+
index: MatchIndex,
|
|
344
|
+
document: unknown,
|
|
345
|
+
match: MigrationMatch,
|
|
346
|
+
): MigrationPath[] {
|
|
347
|
+
const kind = isPlainObject(document) ? document.kind : undefined;
|
|
348
|
+
if (typeof kind !== "string") return [];
|
|
349
|
+
if (!match.inKind.includes(kind) && !match.inKind.includes(MATCH_ANY)) return [];
|
|
350
|
+
|
|
351
|
+
const sites = index.get(match.key);
|
|
352
|
+
if (!sites) return [];
|
|
353
|
+
|
|
354
|
+
const anyRoot = match.under.includes(MATCH_ANY);
|
|
355
|
+
const out: MigrationPath[] = [];
|
|
356
|
+
for (const site of sites) {
|
|
357
|
+
// The index may be shared with rules naming other regions, so the anchor is
|
|
358
|
+
// re-checked per rule. A numeric first segment cannot occur — a document is
|
|
359
|
+
// a mapping — but the guard keeps the containment claim independent of that.
|
|
360
|
+
const anchor = site.path[0];
|
|
361
|
+
if (!anyRoot && (typeof anchor !== "string" || !match.under.includes(anchor))) continue;
|
|
362
|
+
// The schema region — the same ancestry rule every schema-bounded surface
|
|
363
|
+
// reads, so the migration and the validators cannot disagree about where a
|
|
364
|
+
// schema is.
|
|
365
|
+
if (match.inSchema && !isInSchemaRegion(site.path)) continue;
|
|
366
|
+
// `notUnder` subtracts within the region, so it reads the whole path. A
|
|
367
|
+
// numeric segment never equals a key name, so the raw path is enough.
|
|
368
|
+
if (match.notUnder?.some((segment) => site.path.includes(segment))) continue;
|
|
369
|
+
if (!valueMatches(match, site.value)) continue;
|
|
370
|
+
if (match.withSibling !== undefined && !Object.hasOwn(site.parent, match.withSibling)) continue;
|
|
371
|
+
out.push(site.path);
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Convenience for a single match against a document — builds a one-rule index.
|
|
377
|
+
* The driver uses `applicableRules` + `buildMatchIndex` + `selectMatches` so
|
|
378
|
+
* one walk serves every rule that can reach the document. */
|
|
379
|
+
export function findMatches(document: unknown, match: MigrationMatch): MigrationPath[] {
|
|
380
|
+
const index = buildMatchIndex(document, new Set([match.key]), new Set(match.under));
|
|
381
|
+
return selectMatches(index, document, match);
|
|
382
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
|
|
12
|
+
import { makeTaggedSentinel } from "@telorun/templating";
|
|
13
|
+
import { deepEquals } from "./match.js";
|
|
14
|
+
import type {
|
|
15
|
+
MigrationOperation,
|
|
16
|
+
MigrationPath,
|
|
17
|
+
MigrationRefusal,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** A resolved, unconditionally applicable edit. */
|
|
21
|
+
export type MigrationEffect =
|
|
22
|
+
| { readonly kind: "rename-key"; readonly parent: MigrationPath; readonly from: string; readonly to: string }
|
|
23
|
+
| { readonly kind: "set-value"; readonly path: MigrationPath; readonly value: unknown }
|
|
24
|
+
| { readonly kind: "set-tag"; readonly path: MigrationPath; readonly tag: string; readonly source: string }
|
|
25
|
+
| { readonly kind: "insert-item"; readonly path: MigrationPath; readonly index: number; readonly value: unknown }
|
|
26
|
+
| { readonly kind: "remove-entry"; readonly path: MigrationPath };
|
|
27
|
+
|
|
28
|
+
export interface PatchPlan {
|
|
29
|
+
readonly effects: readonly MigrationEffect[];
|
|
30
|
+
/** Where the matched node lives after the patch. Differs from the matched
|
|
31
|
+
* path only when a `rename-key` moved it. */
|
|
32
|
+
readonly finalPath: MigrationPath;
|
|
33
|
+
/** The value at the match before anything was applied — the "before" half of
|
|
34
|
+
* the generated diagnostic sentence. */
|
|
35
|
+
readonly before: unknown;
|
|
36
|
+
/** The value at the match after the patch, or `undefined` for a removal. */
|
|
37
|
+
readonly after: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type PatchPlanResult =
|
|
41
|
+
| { readonly ok: true; readonly plan: PatchPlan }
|
|
42
|
+
| { readonly ok: false; readonly refusal: MigrationRefusal };
|
|
43
|
+
|
|
44
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The container a path's last segment indexes into, or `undefined` when any
|
|
49
|
+
* hop is missing. An empty path has no container — the document root is not a
|
|
50
|
+
* mapping entry and no operation targets it. */
|
|
51
|
+
function containerOf(
|
|
52
|
+
root: unknown,
|
|
53
|
+
path: MigrationPath,
|
|
54
|
+
): { container: unknown; key: string | number } | undefined {
|
|
55
|
+
if (path.length === 0) return undefined;
|
|
56
|
+
let current: unknown = root;
|
|
57
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
58
|
+
const segment = path[i];
|
|
59
|
+
if (typeof segment === "number") {
|
|
60
|
+
if (!Array.isArray(current)) return undefined;
|
|
61
|
+
current = current[segment];
|
|
62
|
+
} else {
|
|
63
|
+
if (!isPlainObject(current)) return undefined;
|
|
64
|
+
current = current[segment];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { container: current, key: path[path.length - 1]! };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readAt(root: unknown, path: MigrationPath): { found: boolean; value: unknown } {
|
|
71
|
+
const located = containerOf(root, path);
|
|
72
|
+
if (!located) return { found: false, value: undefined };
|
|
73
|
+
const { container, key } = located;
|
|
74
|
+
if (typeof key === "number") {
|
|
75
|
+
if (!Array.isArray(container) || key < 0 || key >= container.length) {
|
|
76
|
+
return { found: false, value: undefined };
|
|
77
|
+
}
|
|
78
|
+
return { found: true, value: container[key] };
|
|
79
|
+
}
|
|
80
|
+
if (!isPlainObject(container) || !Object.hasOwn(container, key)) {
|
|
81
|
+
return { found: false, value: undefined };
|
|
82
|
+
}
|
|
83
|
+
return { found: true, value: container[key] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A scalar YAML can carry behind a tag, and the only thing `set-tag` and
|
|
87
|
+
* `qualify` can operate on. */
|
|
88
|
+
function scalarSource(value: unknown): string | undefined {
|
|
89
|
+
if (typeof value === "string") return value;
|
|
90
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve `ops` at `path` against `root`, or refuse.
|
|
96
|
+
*
|
|
97
|
+
* Refusing is the design's safety valve: the node is left exactly as the author
|
|
98
|
+
* wrote it and the ordinary validator reports it with an accurate message,
|
|
99
|
+
* rather than the migration guessing or dropping a value.
|
|
100
|
+
*/
|
|
101
|
+
export function planPatch(
|
|
102
|
+
root: unknown,
|
|
103
|
+
path: MigrationPath,
|
|
104
|
+
ops: readonly MigrationOperation[],
|
|
105
|
+
): PatchPlanResult {
|
|
106
|
+
const initial = readAt(root, path);
|
|
107
|
+
if (!initial.found) return { ok: false, refusal: "path-not-found" };
|
|
108
|
+
|
|
109
|
+
const effects: MigrationEffect[] = [];
|
|
110
|
+
let currentPath: MigrationPath = path;
|
|
111
|
+
let currentValue = initial.value;
|
|
112
|
+
let removed = false;
|
|
113
|
+
|
|
114
|
+
for (const op of ops) {
|
|
115
|
+
if (removed) return { ok: false, refusal: "path-not-found" };
|
|
116
|
+
|
|
117
|
+
switch (op.op) {
|
|
118
|
+
case "rename-key": {
|
|
119
|
+
const located = containerOf(root, currentPath);
|
|
120
|
+
const key = located?.key;
|
|
121
|
+
if (!located || typeof key !== "string" || !isPlainObject(located.container)) {
|
|
122
|
+
return { ok: false, refusal: "not-a-mapping-entry" };
|
|
123
|
+
}
|
|
124
|
+
if (op.to !== key && Object.hasOwn(located.container, op.to)) {
|
|
125
|
+
// Refuse rather than replace: the occupied destination holds a value
|
|
126
|
+
// the author wrote.
|
|
127
|
+
return { ok: false, refusal: "destination-occupied" };
|
|
128
|
+
}
|
|
129
|
+
const parent = currentPath.slice(0, -1);
|
|
130
|
+
effects.push({ kind: "rename-key", parent, from: key, to: op.to });
|
|
131
|
+
currentPath = [...parent, op.to];
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "set-value": {
|
|
135
|
+
let next: unknown;
|
|
136
|
+
if (op.qualify !== undefined) {
|
|
137
|
+
if (typeof currentValue !== "string") {
|
|
138
|
+
return { ok: false, refusal: "malformed-value" };
|
|
139
|
+
}
|
|
140
|
+
if (currentValue.startsWith(op.qualify)) {
|
|
141
|
+
return { ok: false, refusal: "nothing-to-rewrite" };
|
|
142
|
+
}
|
|
143
|
+
next = `${op.qualify}${currentValue}`;
|
|
144
|
+
} else {
|
|
145
|
+
// Same refusal as `qualify`'s, and for the same reason: a rule should
|
|
146
|
+
// match only the legacy spelling, so a write of the value already
|
|
147
|
+
// there means the matcher was too wide. Reporting it would be a
|
|
148
|
+
// deprecation reading `type: string is now written type: string`.
|
|
149
|
+
if (deepEquals(op.value, currentValue)) {
|
|
150
|
+
return { ok: false, refusal: "nothing-to-rewrite" };
|
|
151
|
+
}
|
|
152
|
+
next = op.value;
|
|
153
|
+
}
|
|
154
|
+
effects.push({ kind: "set-value", path: currentPath, value: next });
|
|
155
|
+
currentValue = next;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case "set-tag": {
|
|
159
|
+
const source = scalarSource(currentValue);
|
|
160
|
+
if (source === undefined) return { ok: false, refusal: "not-a-scalar" };
|
|
161
|
+
effects.push({ kind: "set-tag", path: currentPath, tag: op.tag, source });
|
|
162
|
+
currentValue = makeTaggedSentinel(op.tag, source);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case "insert-item": {
|
|
166
|
+
if (!Array.isArray(currentValue)) return { ok: false, refusal: "not-a-sequence" };
|
|
167
|
+
const index =
|
|
168
|
+
op.at === undefined
|
|
169
|
+
? currentValue.length
|
|
170
|
+
: Math.max(0, Math.min(op.at, currentValue.length));
|
|
171
|
+
effects.push({ kind: "insert-item", path: currentPath, index, value: op.value });
|
|
172
|
+
currentValue = [
|
|
173
|
+
...currentValue.slice(0, index),
|
|
174
|
+
op.value,
|
|
175
|
+
...currentValue.slice(index),
|
|
176
|
+
];
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "remove-entry": {
|
|
180
|
+
effects.push({ kind: "remove-entry", path: currentPath });
|
|
181
|
+
currentValue = undefined;
|
|
182
|
+
removed = true;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
plan: {
|
|
191
|
+
effects,
|
|
192
|
+
finalPath: currentPath,
|
|
193
|
+
before: initial.value,
|
|
194
|
+
after: removed ? undefined : currentValue,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Apply a plan to the in-memory manifest tree. Mutates in place — the tree is
|
|
200
|
+
* the loader's own projection of the document, never the author's file. */
|
|
201
|
+
export function applyEffectsToTree(root: unknown, effects: readonly MigrationEffect[]): void {
|
|
202
|
+
for (const effect of effects) {
|
|
203
|
+
switch (effect.kind) {
|
|
204
|
+
case "rename-key": {
|
|
205
|
+
const parent =
|
|
206
|
+
effect.parent.length === 0 ? root : readAt(root, effect.parent).value;
|
|
207
|
+
if (!isPlainObject(parent)) continue;
|
|
208
|
+
// Rebuilt in place so the renamed key keeps its position. Key order is
|
|
209
|
+
// what a round-trip consumer and a rendered diff both read.
|
|
210
|
+
const entries = Object.entries(parent);
|
|
211
|
+
for (const [key] of entries) delete parent[key];
|
|
212
|
+
for (const [key, value] of entries) {
|
|
213
|
+
parent[key === effect.from ? effect.to : key] = value;
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case "set-value":
|
|
218
|
+
case "set-tag": {
|
|
219
|
+
const located = containerOf(root, effect.path);
|
|
220
|
+
if (!located) continue;
|
|
221
|
+
const next =
|
|
222
|
+
effect.kind === "set-value"
|
|
223
|
+
? effect.value
|
|
224
|
+
: makeTaggedSentinel(effect.tag, effect.source);
|
|
225
|
+
writeAt(located.container, located.key, next);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case "insert-item": {
|
|
229
|
+
const target = readAt(root, effect.path).value;
|
|
230
|
+
if (!Array.isArray(target)) continue;
|
|
231
|
+
target.splice(effect.index, 0, effect.value);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
case "remove-entry": {
|
|
235
|
+
const located = containerOf(root, effect.path);
|
|
236
|
+
if (!located) continue;
|
|
237
|
+
if (typeof located.key === "number") {
|
|
238
|
+
if (Array.isArray(located.container)) located.container.splice(located.key, 1);
|
|
239
|
+
} else if (isPlainObject(located.container)) {
|
|
240
|
+
delete located.container[located.key];
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function writeAt(container: unknown, key: string | number, value: unknown): void {
|
|
249
|
+
if (typeof key === "number") {
|
|
250
|
+
if (Array.isArray(container)) container[key] = value;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (isPlainObject(container)) container[key] = value;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Dotted rendering of a path, in the form the position index and every
|
|
257
|
+
* diagnostic's `data.path` use: `routes[0].handler`. */
|
|
258
|
+
export function formatMigrationPath(path: MigrationPath): string {
|
|
259
|
+
let out = "";
|
|
260
|
+
for (const segment of path) {
|
|
261
|
+
if (typeof segment === "number") out += `[${segment}]`;
|
|
262
|
+
else out += out.length === 0 ? segment : `.${segment}`;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|