@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
package/src/index.ts CHANGED
@@ -65,7 +65,7 @@ export {
65
65
  PERMISSIVE_CONTRACT,
66
66
  resolveContract,
67
67
  resolveContractSchema,
68
- withStreamPropertiesSkipped,
68
+ withLiveValuesSkipped,
69
69
  } from "./invocation-contract.js";
70
70
  export type { ContractOrigin, ContractScope, ResolvedContract } from "./invocation-contract.js";
71
71
  export {
@@ -97,7 +97,18 @@ export {
97
97
  transfersControl,
98
98
  } from "./ref-slot.js";
99
99
  export type { RefSlot, RefUse, RefUseCases } from "./ref-slot.js";
100
- export { binaryKeyword, isBinarySlot, X_TELO_BINARY } from "./binary-slot.js";
100
+ export {
101
+ ANNOTATION_KEYWORDS,
102
+ registerTeloKeywords,
103
+ valueTypeKeyword,
104
+ } from "./value-type-keyword.js";
105
+ export {
106
+ applyTextEdits,
107
+ isPlainSafe,
108
+ quoteStyleOf,
109
+ renderFixReplacement,
110
+ } from "./yaml-source-edit.js";
111
+ export type { QuoteStyle, TextEdit } from "./yaml-source-edit.js";
101
112
  export {
102
113
  hasProvidesZone,
103
114
  hasRequiresZone,
@@ -123,6 +134,10 @@ export { validateZoneSlotDeclarations } from "./validate-zone-slots.js";
123
134
  export type { ZoneSlotIssue } from "./validate-zone-slots.js";
124
135
  export { validateDynamicSelectors, validateRefSlotDeclarations } from "./validate-ref-slots.js";
125
136
  export type { RefSlotIssue } from "./validate-ref-slots.js";
137
+ export { validateValueTypeSlots } from "./validate-value-type-slots.js";
138
+ export type { ValueTypeSlotIssue } from "./validate-value-type-slots.js";
139
+ export { checkSchemaCompatibility } from "./schema-compat.js";
140
+ export type { CompatibilityResult } from "./schema-compat.js";
126
141
  export { visitManifest } from "./manifest-visitor.js";
127
142
  export type {
128
143
  CelSiteEvent,
@@ -139,6 +154,15 @@ export { isModuleKind, MODULE_KINDS } from "./module-kinds.js";
139
154
  export type { ModuleKind } from "./module-kinds.js";
140
155
  export { parseLoadedFile } from "./parse-loaded-file.js";
141
156
  export type { ParseOptions } from "./parse-loaded-file.js";
157
+ export {
158
+ CORE_MIGRATIONS,
159
+ migrateFileText,
160
+ migrateManifests,
161
+ NO_MIGRATIONS,
162
+ parseMigrationEntry,
163
+ remapMigratedPaths,
164
+ } from "./migrations/index.js";
165
+ export type { FileMigrations, MigrationEntry, MigrationRewrite } from "./migrations/index.js";
142
166
  export { desugarLoadedFile, inlineImportManifests } from "./inline-imports.js";
143
167
  export type { SyntheticImport } from "./inline-imports.js";
144
168
  export {
@@ -207,6 +231,8 @@ export type {
207
231
  PlatformAxis,
208
232
  PlatformTarget,
209
233
  } from "./artifact-selector.js";
234
+ export { collectModuleFileClaims } from "./module-file-claims.js";
235
+ export type { ModuleFileClaim } from "./module-file-claims.js";
210
236
  export {
211
237
  LayerIndexError,
212
238
  matchControllerLayers,
@@ -224,10 +250,12 @@ export { documentToAst, parseToAst } from "./yaml-ast.js";
224
250
  export type { AstDocument, AstMap, AstNode, AstPair, AstScalar, AstSeq } from "./yaml-ast.js";
225
251
  export { CelParseError, buildCelSegments, wrapCelAst } from "./cel-ast.js";
226
252
  export type { CelNode, CelSegment } from "./cel-ast.js";
227
- export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity } from "./types.js";
253
+ export { DEFAULT_MANIFEST_FILENAME, DiagnosticSeverity, diagnosticFix } from "./types.js";
228
254
  export type {
229
255
  AnalysisDiagnostic,
230
256
  AnalysisOptions,
257
+ DiagnosticData,
258
+ DiagnosticFix,
231
259
  LoaderInitOptions,
232
260
  LoadOptions,
233
261
  ManifestSource,
@@ -1,4 +1,4 @@
1
- import type { ResourceDefinition } from "@telorun/sdk";
1
+ import { isLiveSlot, type ResourceDefinition } from "@telorun/sdk";
2
2
  import {
3
3
  type ContractDirection,
4
4
  contractDeclarer,
@@ -108,33 +108,41 @@ export function resolveContractSchema(
108
108
  }
109
109
 
110
110
  /**
111
- * A copy of `schema` with every `x-telo-stream`-marked property removed from
112
- * `properties` and `required`, for validating a runtime value against.
111
+ * A copy of `schema` with every `live`-typed node left unconstrained, for
112
+ * validating a runtime value against.
113
113
  *
114
- * Streams travel in BOTH directions — `Codec.Encoder` marks `input` on its
115
- * `inputType` and lists it in `required`, and `Record.Stream`, `Ai`, `Tar` and
116
- * `Console` do the same — so a one-directional skip would walk a live `Stream`
117
- * with AJV on the hottest path in the runtime. That is the same defect as
118
- * `stripCompiledValues` walking a live `ResourceInstance` in a ref slot: a live
119
- * object in a declared slot is not data to be traversed. The annotation already
120
- * marks exactly the properties to leave alone.
114
+ * Live values travel in BOTH directions — `Codec.Encoder` declares a stream on
115
+ * its `inputType` and lists it in `required`, and `Record.Stream`, `Ai`, `Tar`
116
+ * and `Console` do the same — so a one-directional skip would walk a live
117
+ * `Stream` with AJV on the hottest path in the runtime. That is the same defect
118
+ * as `stripCompiledValues` walking a live `ResourceInstance` in a ref slot: a
119
+ * live object in a declared slot is not data to be traversed.
120
+ *
121
+ * EXEMPTION IS A PROPERTY OF THE TYPE, not of a position. This used to neutralize
122
+ * only a key it found in a `properties` map, so an array-OF-streams element was
123
+ * reached and left constrained even though the walk descended into `items`.
124
+ * Reading the exemption off the declared value type makes an item, a union branch
125
+ * and a property the same case, and it is one rule instead of three.
126
+ *
127
+ * The exemption is from VALIDATION, never from TYPING: a live type's declared
128
+ * arguments still travel through every schema-typing walk the analyzer performs.
121
129
  *
122
130
  * Structural (returns a new object, never mutates), and shared so the analyzer
123
131
  * and the kernel exempt the same set.
124
132
  */
125
- export function withStreamPropertiesSkipped(
133
+ export function withLiveValuesSkipped(
126
134
  schema: Record<string, any>,
127
135
  /** Resolves a `$ref` to the schema it names. Required to see through the
128
136
  * reference form the runtime deliberately KEEPS intact for its validator: a
129
137
  * contract written as `{ $ref: "telo:mod/Type" }` has none of its own
130
138
  * properties, so a walk that cannot follow the reference exempts nothing and
131
- * the stream is traversed after all. */
139
+ * the live value is traversed after all. */
132
140
  resolveRef?: (ref: string) => Record<string, any> | undefined,
133
141
  ): Record<string, any> {
134
- return stripStreams(schema, [], resolveRef);
142
+ return stripLive(schema, [], resolveRef);
135
143
  }
136
144
 
137
- function stripStreams(
145
+ function stripLive(
138
146
  node: unknown,
139
147
  // A PATH-scoped guard, not a global memo: a schema object reached twice from
140
148
  // different parents must be stripped twice (a global `seen` would hand the
@@ -145,7 +153,7 @@ function stripStreams(
145
153
  if (Array.isArray(node)) {
146
154
  let changed = false;
147
155
  const items = node.map((item) => {
148
- const next = stripStreams(item, path, resolveRef);
156
+ const next = stripLive(item, path, resolveRef);
149
157
  if (next !== item) changed = true;
150
158
  return next;
151
159
  });
@@ -155,6 +163,14 @@ function stripStreams(
155
163
  let schema = node as Record<string, any>;
156
164
  if (path.includes(schema)) return schema;
157
165
 
166
+ // The one rule. An empty schema leaves the node DECLARED but unconstrained,
167
+ // rather than deleted: deleting a property would force `additionalProperties:
168
+ // false` open, and a closed contract would stop rejecting unknown keys the
169
+ // moment it grew a stream — trading one exemption for a hole across the whole
170
+ // shape. `required` is untouched for the same reason: a live value IS present,
171
+ // it is only not walked.
172
+ if (isLiveSlot(schema)) return {};
173
+
158
174
  // Follow a whole-document reference to SEE the annotations behind it, but
159
175
  // return the original node when nothing behind it was stripped. Substituting
160
176
  // the resolved target unconditionally would break schema identity — the
@@ -163,7 +179,7 @@ function stripStreams(
163
179
  if (resolveRef && typeof schema.$ref === "string") {
164
180
  const target = resolveRef(schema.$ref);
165
181
  if (target && !path.includes(target)) {
166
- const stripped = stripStreams(target, [...path, schema], resolveRef);
182
+ const stripped = stripLive(target, [...path, schema], resolveRef);
167
183
  if (stripped === target) return node;
168
184
  const { $ref: _ref, ...siblings } = schema;
169
185
  return Object.keys(siblings).length > 0 ? { ...stripped, ...siblings } : stripped;
@@ -171,44 +187,23 @@ function stripStreams(
171
187
  }
172
188
  const here = [...path, schema];
173
189
 
174
- let out: Record<string, any> = schema;
175
- const properties = schema.properties as Record<string, any> | undefined;
176
- if (properties) {
177
- // A stream can be contributed by an `allOf` branch too (how type inheritance
178
- // is expressed before the branches are merged), so the marked set is read
179
- // from the folded view while the removal is applied here.
180
- const streamed = Object.keys(properties).filter(
181
- (key) => (properties[key] as Record<string, any> | undefined)?.["x-telo-stream"],
182
- );
183
- if (streamed.length > 0) {
184
- const kept: Record<string, any> = {};
185
- for (const [key, value] of Object.entries(properties)) {
186
- if (!streamed.includes(key)) kept[key] = value;
187
- }
188
- // The key stays DECLARED but unconstrained, rather than being deleted.
189
- // Deleting it would force `additionalProperties: false` open, and a closed
190
- // contract would stop rejecting unknown keys the moment it grew a stream —
191
- // trading one exemption for a hole across the whole shape.
192
- for (const key of streamed) kept[key] = {};
193
- out = { ...schema, properties: kept };
194
- }
195
- }
196
-
197
- // Recurse: a stream one level down (an item, a branch, a nested object) is as
198
- // live as one at the root, and walking it with AJV is the same defect.
190
+ // Recurse: a live value one level down (a property, an item, a branch, a
191
+ // nested object) is as live as one at the root, and walking it with AJV is the
192
+ // same defect. Each is neutralized by the single rule above when the walk
193
+ // reaches it.
199
194
  //
200
195
  // `properties` and `$defs` are MAPS of schemas, not schemas — descending into
201
196
  // them as if they were would visit nothing, since a map has none of the
202
197
  // keywords this walk looks for.
203
- let changed = out !== schema;
204
- const result: Record<string, any> = { ...out };
198
+ let changed = false;
199
+ const result: Record<string, any> = { ...schema };
205
200
  for (const key of ["properties", "$defs"] as const) {
206
- const map = out[key] as Record<string, any> | undefined;
201
+ const map = schema[key] as Record<string, any> | undefined;
207
202
  if (!map || typeof map !== "object") continue;
208
203
  let mapChanged = false;
209
204
  const next: Record<string, any> = {};
210
205
  for (const [name, child] of Object.entries(map)) {
211
- const stripped = stripStreams(child, here, resolveRef);
206
+ const stripped = stripLive(child, here, resolveRef);
212
207
  if (stripped !== child) mapChanged = true;
213
208
  next[name] = stripped;
214
209
  }
@@ -218,9 +213,9 @@ function stripStreams(
218
213
  }
219
214
  }
220
215
  for (const key of ["items", "allOf", "anyOf", "oneOf"] as const) {
221
- const child = out[key];
216
+ const child = schema[key];
222
217
  if (child === undefined) continue;
223
- const next = stripStreams(child, here, resolveRef);
218
+ const next = stripLive(child, here, resolveRef);
224
219
  if (next !== child) {
225
220
  result[key] = next;
226
221
  changed = true;
@@ -237,7 +232,7 @@ function stripStreams(
237
232
  * schema's defaults rather than by the size of the payload. */
238
233
  export function defaultBearingPaths(
239
234
  schema: Record<string, any>,
240
- /** See {@link withStreamPropertiesSkipped} — a contract kept in `$ref` form
235
+ /** See {@link withLiveValuesSkipped} — a contract kept in `$ref` form
241
236
  * declares its defaults behind the reference, and a walk that cannot follow
242
237
  * it would report none, leaving the caller's data shared where a fill lands. */
243
238
  resolveRef?: (ref: string) => Record<string, any> | undefined,
@@ -1,5 +1,6 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
2
  import type { Document } from "yaml";
3
+ import type { FileMigrations } from "./migrations/driver.js";
3
4
  import type { DocumentPosition } from "./position-metadata.js";
4
5
  import type { AnalysisDiagnostic, Range } from "./types.js";
5
6
  import type { AstDocument } from "./yaml-ast.js";
@@ -31,6 +32,12 @@ export interface LoadedFile {
31
32
  positions: DocumentPosition[];
32
33
  /** Document-level parse errors aggregated from `yaml.Document.errors`. */
33
34
  parseErrors: ParseError[];
35
+ /** What the migration phase rewrote in this file, and the diagnostics for it.
36
+ * Empty unless the load opted into `migrate` AND a legacy spelling matched.
37
+ * `manifests` above are the POST-migration tree; `documents` / `astDocuments`
38
+ * / `positions` / `text` are always the author's file untouched, which is
39
+ * what the recorded `legacyPath` resolves against. */
40
+ migrations: FileMigrations;
34
41
  }
35
42
 
36
43
  export interface ParseError {
@@ -93,6 +100,12 @@ export interface LoadedGraph {
93
100
  * it independently re-resolves an import (the analyzer already sees repointed
94
101
  * `importEdges`). Empty when no module identity appeared at two sources. */
95
102
  overrides: Map<string, string>;
103
+ /** Deprecation diagnostics from the loader's migration phase, scoped to the
104
+ * ENTRY's own module (owner + its `include:` partials). A migration rewrites
105
+ * always — the runtime must read artifacts published years ago — but reports
106
+ * only here, because a published dependency is not the consumer's to fix.
107
+ * Empty unless the load opted into `migrate`. */
108
+ migrationDiagnostics: AnalysisDiagnostic[];
96
109
  /** Diagnostics produced while reconciling module versions — one per import
97
110
  * edge redirected to a different version (warning for a same-major hoist,
98
111
  * error for a major mismatch). Surfaced alongside `analyze()` diagnostics by
@@ -9,6 +9,7 @@ import type {
9
9
  LoadedModule,
10
10
  } from "./loaded-types.js";
11
11
  import { desugarLoadedFile } from "./inline-imports.js";
12
+ import type { MigrationEntry } from "./migrations/types.js";
12
13
  import { isModuleKind } from "./module-kinds.js";
13
14
  import { parseLoadedFile } from "./parse-loaded-file.js";
14
15
  import { reconcileModuleVersions } from "./reconcile-module-versions.js";
@@ -44,6 +45,18 @@ function collectParseDiagnostics(
44
45
  return diagnostics;
45
46
  }
46
47
 
48
+ /** Rewrite always, report locally. Every file in the graph was migrated, but a
49
+ * published dependency's manifest is not the consumer's to fix and its author
50
+ * is the only person who can — so only the entry module's own files (owner +
51
+ * its `include:` partials) report. Same rule `X_TELO_REF_UNRESOLVED` follows. */
52
+ function collectMigrationDiagnostics(entry: LoadedModule): AnalysisDiagnostic[] {
53
+ const diagnostics: AnalysisDiagnostic[] = [];
54
+ for (const file of [entry.owner, ...entry.partials]) {
55
+ diagnostics.push(...file.migrations.diagnostics);
56
+ }
57
+ return diagnostics;
58
+ }
59
+
47
60
  const SYSTEM_KINDS = new Set([
48
61
  "Telo.Application",
49
62
  "Telo.Library",
@@ -51,12 +64,19 @@ const SYSTEM_KINDS = new Set([
51
64
  "Telo.Definition",
52
65
  ]);
53
66
 
54
- /** File cache variant tags: compile (c/r) × desugarImports (d/n). A desugared
55
- * and a raw load of the same file are distinct entries so neither sees the
56
- * wrong manifest tree. */
57
- const CACHE_VARIANTS = ["rn", "rd", "cn", "cd"] as const;
67
+ /** File cache variant tags: compile (c/r) × desugarImports (d/n) × migrate
68
+ * (m/x). A desugared and a raw load of the same file are distinct entries so
69
+ * neither sees the wrong manifest tree, and the migration axis is there for
70
+ * the same reason the editor's round-trip view and `telo migrate` must see
71
+ * the author's spelling, everything else the current one. */
72
+ const CACHE_VARIANTS = [
73
+ "rnx", "rdx", "cnx", "cdx",
74
+ "rnm", "rdm", "cnm", "cdm",
75
+ ] as const;
58
76
  function variantKey(options?: LoadOptions): string {
59
- return `${options?.compile ? "c" : "r"}${options?.desugarImports ? "d" : "n"}`;
77
+ return `${options?.compile ? "c" : "r"}${options?.desugarImports ? "d" : "n"}${
78
+ options?.migrate ? "m" : "x"
79
+ }`;
60
80
  }
61
81
 
62
82
  export class Loader {
@@ -76,6 +96,7 @@ export class Loader {
76
96
 
77
97
  protected sources: ManifestSource[];
78
98
  private readonly celEnv: Environment;
99
+ private readonly migrations?: readonly MigrationEntry[];
79
100
 
80
101
  /** Sources are resolved in order — the first whose `supports(url)` matches
81
102
  * wins. The caller (composition root) decides which concrete sources exist
@@ -85,6 +106,7 @@ export class Loader {
85
106
  constructor(sources: ManifestSource[] = [], options: LoaderInitOptions = {}) {
86
107
  this.sources = [...sources];
87
108
  this.celEnv = buildCelEnvironment(options.celHandlers);
109
+ this.migrations = options.migrations;
88
110
  }
89
111
 
90
112
  register(source: ManifestSource): this {
@@ -188,7 +210,13 @@ export class Loader {
188
210
  * caller opted in. Desugaring lives here, not in the pure `parseLoadedFile`,
189
211
  * so round-trip consumers (the editor) keep a raw manifest/AST/position
190
212
  * triple they can pair by index; only resolved consumers that pass
191
- * `desugarImports` see synthetic Telo.Import manifests. */
213
+ * `desugarImports` see synthetic Telo.Import manifests.
214
+ *
215
+ * The migration phase runs inside `parseLoadedFile`, i.e. before desugaring —
216
+ * a rule must only ever match author-written nodes, and the position is what
217
+ * makes that structural rather than a convention. Nothing needs the later
218
+ * position: the `imports:` map is read straight off the module manifest and is
219
+ * equally available before desugaring. */
192
220
  private parseAndMaybeDesugar(
193
221
  source: string,
194
222
  requestedUrl: string,
@@ -198,6 +226,8 @@ export class Loader {
198
226
  const loaded = parseLoadedFile(source, requestedUrl, text, {
199
227
  compile: options?.compile,
200
228
  celEnv: this.celEnv,
229
+ migrate: options?.migrate,
230
+ migrations: this.migrations,
201
231
  });
202
232
  return options?.desugarImports ? desugarLoadedFile(loaded) : loaded;
203
233
  }
@@ -364,6 +394,7 @@ export class Loader {
364
394
  modules,
365
395
  importEdges,
366
396
  overrides,
397
+ migrationDiagnostics: collectMigrationDiagnostics(entry),
367
398
  versionDiagnostics: diagnostics,
368
399
  parseDiagnostics: collectParseDiagnostics(modules),
369
400
  errors,
@@ -1,5 +1,10 @@
1
1
  import type { ResourceDefinition, ResourceManifest } from "@telorun/sdk";
2
- import { isRefSentinel, isTaggedSentinel, walkCelExpressions } from "@telorun/templating";
2
+ import {
3
+ isRefSentinel,
4
+ isTaggedSentinel,
5
+ walkCelExpressions,
6
+ type CelSurface,
7
+ } from "@telorun/templating";
3
8
  import type { AliasResolver } from "./alias-resolver.js";
4
9
  import type { DefinitionRegistry } from "./definition-registry.js";
5
10
  import {
@@ -114,6 +119,9 @@ export interface CelSiteEvent {
114
119
  contextSchema?: Record<string, any>;
115
120
  /** Scope of the matched context (e.g. `$.routes[*].handler`), if matched. */
116
121
  matchedScope?: string;
122
+ /** Where `expr` sits in the scalar at `path`, and the delimiters to restore
123
+ * around a corrected expression. See `CelSurface`. */
124
+ surface: CelSurface;
117
125
  }
118
126
 
119
127
  export interface ManifestVisitor {
@@ -352,7 +360,7 @@ export function visitManifest(
352
360
 
353
361
  if (wantsCel) {
354
362
  const contexts = definition?.schema ? extractContextsFromSchema(definition.schema) : [];
355
- walkCelExpressions(r, "", (expr, path, engineName) => {
363
+ walkCelExpressions(r, "", (expr, path, engineName, surface) => {
356
364
  let contextSchema: Record<string, any> | undefined;
357
365
  let matchedScope: string | undefined;
358
366
  for (const ctx of contexts) {
@@ -362,7 +370,7 @@ export function visitManifest(
362
370
  break;
363
371
  }
364
372
  }
365
- visitor.onCel!({ source: r, path, expr, engineName, contextSchema, matchedScope });
373
+ visitor.onCel!({ source: r, path, expr, engineName, contextSchema, matchedScope, surface });
366
374
  });
367
375
  }
368
376
 
@@ -0,0 +1,262 @@
1
+ /** The migration driver — one pass, one set of guarantees.
2
+ *
3
+ * **Composition is the driver's guarantee, not each entry's proof
4
+ * obligation.** The match set is frozen against the pre-migration tree, so no
5
+ * rule can match a node another rule produced; rules within an entry apply in
6
+ * order at each match; entries never see one another's output. That matters
7
+ * once core and module entries are aggregated from different parties, where
8
+ * "it happens to work" is not determinism. Idempotency then follows from the
9
+ * driver rather than from every author getting it right: a rule matches only
10
+ * the legacy spelling, and re-running finds nothing.
11
+ *
12
+ * How a rewrite is REPORTED — the provenance record and the diagnostic — is
13
+ * `report.ts`. Nothing there can change what a migration does, and nothing
14
+ * here decides how it reads. */
15
+
16
+ import type { ResourceManifest } from "@telorun/sdk";
17
+ import type { Document } from "yaml";
18
+ import {
19
+ applyEffectsToTree,
20
+ formatMigrationPath,
21
+ planPatch,
22
+ type MigrationEffect,
23
+ } from "./patch.js";
24
+ import { applyTextEdits, planTextEdits, type TextEdit } from "./yaml-edit.js";
25
+ import { applicableRules, buildMatchIndex, selectMatches } from "./match.js";
26
+ import { CORE_MIGRATIONS } from "./registry.js";
27
+ import { toDiagnostic, toRewrite, type AppliedPatch } from "./report.js";
28
+ import type {
29
+ MigrationEntry,
30
+ MigrationOperation,
31
+ MigrationPath,
32
+ MigrationRewrite,
33
+ } from "./types.js";
34
+ import type { AnalysisDiagnostic } from "../types.js";
35
+
36
+ /** What one file's migration produced. */
37
+ export interface FileMigrations {
38
+ /** One record per applied rewrite, in application order. Empty when the file
39
+ * carried no legacy spelling — the overwhelmingly common case. */
40
+ readonly rewrites: readonly MigrationRewrite[];
41
+ /** One diagnostic per rewrite. Whether they are SURFACED is the graph's
42
+ * decision: a migration rewrites always, because the runtime must read
43
+ * artifacts published years ago, but reports only for the entry's own
44
+ * modules, because a published dependency is not the consumer's to fix. */
45
+ readonly diagnostics: readonly AnalysisDiagnostic[];
46
+ }
47
+
48
+ export const NO_MIGRATIONS: FileMigrations = { rewrites: [], diagnostics: [] };
49
+
50
+ /**
51
+ * Run `entries` over one file's parsed documents, mutating `manifests` in
52
+ * place. Returns the provenance records and their diagnostics.
53
+ *
54
+ * The manifests are the loader's own projection of the file, never the
55
+ * author's text — nothing here can reach disk.
56
+ */
57
+ export function migrateManifests(args: {
58
+ source: string;
59
+ manifests: Array<ResourceManifest | null>;
60
+ entries?: readonly MigrationEntry[];
61
+ }): FileMigrations {
62
+ const entries = args.entries ?? CORE_MIGRATIONS;
63
+ if (entries.length === 0) return NO_MIGRATIONS;
64
+
65
+ const applied = applyAll(args.manifests, entries);
66
+ if (applied.length === 0) return NO_MIGRATIONS;
67
+
68
+ const rewrites = applied.map((a) => toRewrite(a));
69
+ const diagnostics = applied.map((a, i) =>
70
+ toDiagnostic(a, rewrites[i]!, args.source, args.manifests),
71
+ );
72
+ return { rewrites, diagnostics };
73
+ }
74
+
75
+ /**
76
+ * Migrate the author's YAML instead of the loader's tree — the operation
77
+ * `telo migrate` is the reference application of.
78
+ *
79
+ * `manifests` must be the RAW (un-migrated) projection of `documents`, since
80
+ * the matchers select legacy spellings. Returns `null` when nothing matched, so
81
+ * a caller can leave an untouched file untouched rather than rewriting
82
+ * identical bytes.
83
+ *
84
+ * `unwritable` names every rewrite the tree accepted but the TEXT could not
85
+ * express. The two appliers can disagree only in this direction, and the
86
+ * disagreement has to be reported: the diagnostic that sent the author here
87
+ * says "run `telo migrate`", so a location this silently skipped would keep
88
+ * warning with no way to act on it.
89
+ */
90
+ export function migrateFileText(args: {
91
+ source: string;
92
+ text: string;
93
+ documents: readonly Document[];
94
+ manifests: ReadonlyArray<ResourceManifest | null>;
95
+ entries?: readonly MigrationEntry[];
96
+ }): {
97
+ text: string;
98
+ rewrites: MigrationRewrite[];
99
+ unwritable: MigrationRewrite[];
100
+ } | null {
101
+ const entries = args.entries ?? CORE_MIGRATIONS;
102
+ if (entries.length === 0) return null;
103
+
104
+ // Planned against a copy: the tree walk is what decides which patches apply
105
+ // (and in what order they refuse), so the YAML side must ask exactly the same
106
+ // question of exactly the same state rather than re-deriving it.
107
+ const scratch = args.manifests.map((m) => (m ? (structuredClone(m) as ResourceManifest) : null));
108
+ const applied = applyAll(scratch, entries);
109
+ if (applied.length === 0) return null;
110
+
111
+ const edits: TextEdit[] = [];
112
+ const kept: AppliedPatch[] = [];
113
+ const skipped: AppliedPatch[] = [];
114
+ for (const patch of applied) {
115
+ const doc = args.documents[patch.documentIndex];
116
+ if (!doc) {
117
+ skipped.push(patch);
118
+ continue;
119
+ }
120
+ const planned = planTextEdits(doc, args.text, patch.plan.effects);
121
+ // A patch the tree accepted but the text cannot express (a block scalar
122
+ // span, a flow-style entry) leaves that node alone rather than half-written.
123
+ if (!planned) {
124
+ skipped.push(patch);
125
+ continue;
126
+ }
127
+ // Two patches whose spans overlap cannot both be spliced — the second would
128
+ // write into bytes the first replaced. The tree side has no equivalent,
129
+ // because an object write is idempotent where a splice is positional, so
130
+ // this is the one place the file applier is stricter: the later patch is
131
+ // dropped and its node stays as the author wrote it.
132
+ if (planned.some((edit) => edits.some((existing) => overlaps(edit, existing)))) {
133
+ skipped.push(patch);
134
+ continue;
135
+ }
136
+ edits.push(...planned);
137
+ kept.push(patch);
138
+ }
139
+ if (edits.length === 0 && skipped.length === 0) return null;
140
+
141
+ return {
142
+ text: edits.length > 0 ? applyTextEdits(args.text, edits) : args.text,
143
+ rewrites: kept.map(toRewrite),
144
+ unwritable: skipped.map(toRewrite),
145
+ };
146
+ }
147
+
148
+ /** Whether two splices contend for the same bytes. A pure insertion (an empty
149
+ * span) collides only with a span that strictly contains its point. */
150
+ function overlaps(a: TextEdit, b: TextEdit): boolean {
151
+ if (a.start === a.end) return b.start < a.start && a.start < b.end;
152
+ if (b.start === b.end) return a.start < b.start && b.start < a.end;
153
+ return a.start < b.end && b.start < a.end;
154
+ }
155
+
156
+ /** One pass: every match collected against the frozen pre-migration tree, then
157
+ * applied in entry order. */
158
+ function applyAll(
159
+ manifests: Array<ResourceManifest | null>,
160
+ entries: readonly MigrationEntry[],
161
+ ): AppliedPatch[] {
162
+ const candidates: Array<{
163
+ entry: MigrationEntry;
164
+ ops: readonly MigrationOperation[];
165
+ documentIndex: number;
166
+ path: MigrationPath;
167
+ }> = [];
168
+
169
+ // One index per DOCUMENT, shared by every rule that can reach it — the walk
170
+ // is the expensive part and does not depend on the rule. This runs on the
171
+ // kernel's boot path for every file in the graph, so a walk per rule would
172
+ // scale the cost of loading any manifest with the size of the migration set.
173
+ //
174
+ // The `inKind` gate and the `under` regions are both known BEFORE the walk,
175
+ // so they bound it rather than filtering its output: a document no rule
176
+ // targets is never walked, and a region no rule names is never descended
177
+ // into. `type:` alone occurs a couple of hundred times in a standard-library
178
+ // manifest, so indexing sites that cannot be selected is the whole cost.
179
+ const flatRules = entries.flatMap((entry) =>
180
+ entry.rules.map((rule) => ({ entry, ops: rule.patch, match: rule.match })),
181
+ );
182
+ for (let documentIndex = 0; documentIndex < manifests.length; documentIndex++) {
183
+ const manifest = manifests[documentIndex];
184
+ if (!manifest) continue;
185
+ const { rules, keys, roots } = applicableRules(flatRules, manifest.kind);
186
+ if (rules.length === 0) continue;
187
+ const index = buildMatchIndex(manifest, keys, roots);
188
+ for (const { entry, ops, match } of rules) {
189
+ for (const path of selectMatches(index, manifest, match)) {
190
+ candidates.push({ entry, ops, documentIndex, path });
191
+ }
192
+ }
193
+ }
194
+
195
+ // Arrays whose LENGTH an already-applied patch changed, as
196
+ // `<documentIndex>:<dotted path>`. A frozen match names a sequence element by
197
+ // INDEX, and an index is not an identity: once a sibling patch inserted or
198
+ // removed an item, the same index names a different element — including one
199
+ // another rule just produced, which is exactly what the frozen match set
200
+ // exists to prevent. A key-based path needs no such record, because a rename
201
+ // or removal makes the stale path resolve to nothing and `planPatch` refuses
202
+ // it on its own.
203
+ const shiftedArrays = new Set<string>();
204
+
205
+ const applied: AppliedPatch[] = [];
206
+ for (const candidate of candidates) {
207
+ const manifest = manifests[candidate.documentIndex];
208
+ if (!manifest) continue;
209
+ if (indexIsStale(candidate.documentIndex, candidate.path, shiftedArrays)) continue;
210
+ const result = planPatch(manifest, candidate.path, candidate.ops);
211
+ // Refusal is not an error: the node stays as the author wrote it and the
212
+ // ordinary validator reports it with an accurate message.
213
+ if (!result.ok) continue;
214
+ applyEffectsToTree(manifest, result.plan.effects);
215
+ for (const array of resizedArrays(candidate.documentIndex, result.plan.effects)) {
216
+ shiftedArrays.add(array);
217
+ }
218
+ applied.push({
219
+ entry: candidate.entry,
220
+ documentIndex: candidate.documentIndex,
221
+ matched: candidate.path,
222
+ plan: result.plan,
223
+ ops: candidate.ops,
224
+ });
225
+ }
226
+ return applied;
227
+ }
228
+
229
+ /** Arrays this patch resized, keyed for `shiftedArrays`. */
230
+ function resizedArrays(
231
+ documentIndex: number,
232
+ effects: readonly MigrationEffect[],
233
+ ): string[] {
234
+ const out: string[] = [];
235
+ for (const effect of effects) {
236
+ if (effect.kind === "insert-item") {
237
+ out.push(`${documentIndex}:${formatMigrationPath(effect.path)}`);
238
+ } else if (
239
+ effect.kind === "remove-entry" &&
240
+ typeof effect.path[effect.path.length - 1] === "number"
241
+ ) {
242
+ out.push(`${documentIndex}:${formatMigrationPath(effect.path.slice(0, -1))}`);
243
+ }
244
+ }
245
+ return out;
246
+ }
247
+
248
+ /** Whether any index along `path` steps into an array a prior patch resized. */
249
+ function indexIsStale(
250
+ documentIndex: number,
251
+ path: MigrationPath,
252
+ shiftedArrays: ReadonlySet<string>,
253
+ ): boolean {
254
+ if (shiftedArrays.size === 0) return false;
255
+ for (let i = 0; i < path.length; i++) {
256
+ if (typeof path[i] !== "number") continue;
257
+ if (shiftedArrays.has(`${documentIndex}:${formatMigrationPath(path.slice(0, i))}`)) {
258
+ return true;
259
+ }
260
+ }
261
+ return false;
262
+ }