@telorun/analyzer 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/analyzer.js +91 -9
  3. package/dist/cel-environment.d.ts.map +1 -1
  4. package/dist/cel-environment.js +6 -3
  5. package/dist/definition-registry.d.ts +5 -0
  6. package/dist/definition-registry.d.ts.map +1 -1
  7. package/dist/definition-registry.js +9 -0
  8. package/dist/index.d.ts +10 -2
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +6 -2
  11. package/dist/invocation-contract.d.ts +21 -13
  12. package/dist/invocation-contract.d.ts.map +1 -1
  13. package/dist/invocation-contract.js +43 -46
  14. package/dist/loaded-types.d.ts +13 -0
  15. package/dist/loaded-types.d.ts.map +1 -1
  16. package/dist/manifest-loader.d.ts +8 -1
  17. package/dist/manifest-loader.d.ts.map +1 -1
  18. package/dist/manifest-loader.js +33 -6
  19. package/dist/migrations/driver.d.ts +69 -0
  20. package/dist/migrations/driver.d.ts.map +1 -0
  21. package/dist/migrations/driver.js +200 -0
  22. package/dist/migrations/entries/index.d.ts +3 -0
  23. package/dist/migrations/entries/index.d.ts.map +1 -0
  24. package/dist/migrations/entries/index.js +9 -0
  25. package/dist/migrations/entries/normalize-value-types.json +48 -0
  26. package/dist/migrations/entries/ref-slot-scalar-type.json +20 -0
  27. package/dist/migrations/entry-data.d.ts +25 -0
  28. package/dist/migrations/entry-data.d.ts.map +1 -0
  29. package/dist/migrations/entry-data.js +171 -0
  30. package/dist/migrations/index.d.ts +22 -0
  31. package/dist/migrations/index.d.ts.map +1 -0
  32. package/dist/migrations/index.js +19 -0
  33. package/dist/migrations/match.d.ts +162 -0
  34. package/dist/migrations/match.d.ts.map +1 -0
  35. package/dist/migrations/match.js +295 -0
  36. package/dist/migrations/patch.d.ts +68 -0
  37. package/dist/migrations/patch.d.ts.map +1 -0
  38. package/dist/migrations/patch.js +236 -0
  39. package/dist/migrations/provenance.d.ts +25 -0
  40. package/dist/migrations/provenance.d.ts.map +1 -0
  41. package/dist/migrations/provenance.js +118 -0
  42. package/dist/migrations/registry.d.ts +35 -0
  43. package/dist/migrations/registry.d.ts.map +1 -0
  44. package/dist/migrations/registry.js +35 -0
  45. package/dist/migrations/report.d.ts +28 -0
  46. package/dist/migrations/report.d.ts.map +1 -0
  47. package/dist/migrations/report.js +144 -0
  48. package/dist/migrations/types.d.ts +115 -0
  49. package/dist/migrations/types.d.ts.map +1 -0
  50. package/dist/migrations/types.js +24 -0
  51. package/dist/migrations/yaml-edit.d.ts +21 -0
  52. package/dist/migrations/yaml-edit.d.ts.map +1 -0
  53. package/dist/migrations/yaml-edit.js +245 -0
  54. package/dist/parse-loaded-file.d.ts +8 -0
  55. package/dist/parse-loaded-file.d.ts.map +1 -1
  56. package/dist/parse-loaded-file.js +22 -13
  57. package/dist/resolve-schema-type-refs.d.ts +29 -12
  58. package/dist/resolve-schema-type-refs.d.ts.map +1 -1
  59. package/dist/resolve-schema-type-refs.js +89 -16
  60. package/dist/schema-compat.d.ts +35 -9
  61. package/dist/schema-compat.d.ts.map +1 -1
  62. package/dist/schema-compat.js +180 -64
  63. package/dist/schema-region.d.ts +33 -0
  64. package/dist/schema-region.d.ts.map +1 -0
  65. package/dist/schema-region.js +45 -0
  66. package/dist/types.d.ts +17 -0
  67. package/dist/types.d.ts.map +1 -1
  68. package/dist/validate-cel-context.d.ts.map +1 -1
  69. package/dist/validate-cel-context.js +29 -3
  70. package/dist/validate-step-inputs.d.ts +8 -1
  71. package/dist/validate-step-inputs.d.ts.map +1 -1
  72. package/dist/validate-step-inputs.js +47 -2
  73. package/dist/validate-value-type-slots.d.ts +43 -0
  74. package/dist/validate-value-type-slots.d.ts.map +1 -0
  75. package/dist/validate-value-type-slots.js +142 -0
  76. package/dist/value-type-keyword.d.ts +60 -0
  77. package/dist/value-type-keyword.d.ts.map +1 -0
  78. package/dist/value-type-keyword.js +119 -0
  79. package/dist/yaml-source-edit.d.ts +56 -0
  80. package/dist/yaml-source-edit.d.ts.map +1 -0
  81. package/dist/yaml-source-edit.js +93 -0
  82. package/package.json +3 -3
  83. package/src/analyzer.ts +99 -6
  84. package/src/cel-environment.ts +6 -3
  85. package/src/definition-registry.ts +10 -0
  86. package/src/index.ts +26 -2
  87. package/src/invocation-contract.ts +43 -48
  88. package/src/loaded-types.ts +13 -0
  89. package/src/manifest-loader.ts +37 -6
  90. package/src/migrations/driver.ts +262 -0
  91. package/src/migrations/entries/index.ts +10 -0
  92. package/src/migrations/entries/normalize-value-types.json +48 -0
  93. package/src/migrations/entries/ref-slot-scalar-type.json +20 -0
  94. package/src/migrations/entry-data.ts +207 -0
  95. package/src/migrations/index.ts +22 -0
  96. package/src/migrations/match.ts +382 -0
  97. package/src/migrations/patch.ts +265 -0
  98. package/src/migrations/provenance.ts +167 -0
  99. package/src/migrations/registry.ts +40 -0
  100. package/src/migrations/report.ts +169 -0
  101. package/src/migrations/types.ts +144 -0
  102. package/src/migrations/yaml-edit.ts +260 -0
  103. package/src/parse-loaded-file.ts +31 -12
  104. package/src/resolve-schema-type-refs.ts +93 -16
  105. package/src/schema-compat.ts +212 -74
  106. package/src/schema-region.ts +46 -0
  107. package/src/types.ts +17 -0
  108. package/src/validate-cel-context.ts +36 -3
  109. package/src/validate-step-inputs.ts +56 -1
  110. package/src/validate-value-type-slots.ts +182 -0
  111. package/src/value-type-keyword.ts +135 -0
  112. package/src/yaml-source-edit.ts +106 -0
  113. package/dist/binary-slot.d.ts +0 -39
  114. package/dist/binary-slot.d.ts.map +0 -1
  115. package/dist/binary-slot.js +0 -58
  116. package/src/binary-slot.ts +0 -71
package/src/analyzer.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  defaultRegistry,
6
6
  isRefSentinel,
7
7
  isTaggedSentinel,
8
+ plainChainOf,
8
9
  type CelSurface,
9
10
  } from "@telorun/templating";
10
11
  import type { DiagnosticFix } from "./types.js";
@@ -56,11 +57,17 @@ import {
56
57
  validateRefSlotDeclarations,
57
58
  type RefSlotIssue,
58
59
  } from "./validate-ref-slots.js";
60
+ import {
61
+ validateValueTypeSlots,
62
+ type ValueTypeSlotIssue,
63
+ } from "./validate-value-type-slots.js";
59
64
  import { resolveSchemaTypeRefs } from "./resolve-schema-type-refs.js";
60
65
  import { validateSchemaTypeRefs } from "./validate-schema-type-refs.js";
61
66
  import { rewriteSyntheticOrigins } from "./rewrite-synthetic-origins.js";
62
67
  import {
63
68
  celTypeSatisfiesJsonSchema,
69
+ checkSchemaCompatibility,
70
+ navigateSchemaToExprPath,
64
71
  substituteCelFields,
65
72
  validateAgainstSchema,
66
73
  type SchemaIssue,
@@ -1201,6 +1208,18 @@ export class StaticAnalyzer {
1201
1208
  const refConstraintIssues: RefConstraintIssue[] = [];
1202
1209
  const refSlotIssues: RefSlotIssue[] = [];
1203
1210
  const zoneSlotIssues: ZoneSlotIssue[] = [];
1211
+ // `x-telo-type` is checked on EVERY manifest, not only on definition docs: a
1212
+ // schema fragment is written wherever a kind declares a schema-valued field,
1213
+ // so an inline `inputType:` on an ordinary resource carries one just as a
1214
+ // definition's `schema:` does. Same scoping as every other schema issue —
1215
+ // the entry's own modules, since a dependency is not the consumer's to fix.
1216
+ const valueTypeSlotIssues: ValueTypeSlotIssue[] = [];
1217
+ for (const m of manifests) {
1218
+ const declaringModule = (m.metadata as { module?: string } | undefined)?.module;
1219
+ if (!declaringModule || rootModules.has(declaringModule)) {
1220
+ valueTypeSlotIssues.push(...validateValueTypeSlots(m as unknown as ResourceManifest));
1221
+ }
1222
+ }
1204
1223
  for (const m of manifests) {
1205
1224
  if (m.kind !== "Telo.Definition" && m.kind !== "Telo.Abstract") continue;
1206
1225
  const def = m as unknown as ResourceDefinition;
@@ -1340,6 +1359,26 @@ export class StaticAnalyzer {
1340
1359
  // accessor split; `readRefSlot` stays lenient so surfaces keep working
1341
1360
  // mid-migration, and this reports what leniency would silently absorb.
1342
1361
  for (const issue of refSlotIssues) diagnostics.push(refSlotIssueDiagnostic(issue));
1362
+ // The same split for `x-telo-type`. Its reader returns a slot with no
1363
+ // entry for a name it does not know, which is what an unrecognized brand
1364
+ // used to do SILENTLY — the slot simply lost its identity.
1365
+ for (const issue of valueTypeSlotIssues) {
1366
+ diagnostics.push({
1367
+ severity: DiagnosticSeverity.Error,
1368
+ code: issue.code,
1369
+ source: SOURCE,
1370
+ message: issue.message,
1371
+ data: {
1372
+ resource: {
1373
+ kind: issue.manifest.kind,
1374
+ name: issue.manifest.metadata?.name as string,
1375
+ },
1376
+ filePath: (issue.manifest.metadata as { source?: string } | undefined)?.source,
1377
+ path: issue.path,
1378
+ ...(issue.fix ? { fix: issue.fix } : {}),
1379
+ },
1380
+ });
1381
+ }
1343
1382
  // Same split for the two zone annotations. Unreadable ones fail in
1344
1383
  // OPPOSITE directions — a dropped requirement is silently unenforced, a
1345
1384
  // dropped provision invents failures — so neither can be left to
@@ -1625,6 +1664,11 @@ export class StaticAnalyzer {
1625
1664
  filePath?: string;
1626
1665
  })[] = [];
1627
1666
  const celTypeByPath = new Map<ResourceManifest, Map<string, string>>();
1667
+ // The schema an expression RESOLVES TO, beside the CEL type it carries. Both
1668
+ // are needed and neither replaces the other: the CEL type answers "does this
1669
+ // fit the slot at all", the schema answers "do their type arguments agree",
1670
+ // which cel-js cannot express because it types by constructor identity.
1671
+ const celSourceSchemaByPath = new Map<ResourceManifest, Map<string, Record<string, any>>>();
1628
1672
  // Context-free typed environments, one per manifest. Reused across every
1629
1673
  // expression in it — see the build site for why a matched context opts out.
1630
1674
  const typedEnvByManifest = new Map<ResourceManifest, Environment>();
@@ -1962,12 +2006,15 @@ export class StaticAnalyzer {
1962
2006
  defs,
1963
2007
  aliases,
1964
2008
  { aliasesByModule, rootModules },
2009
+ celStepContextSchema,
1965
2010
  )) {
1966
2011
  diagnostics.push({
1967
2012
  severity: DiagnosticSeverity.Error,
1968
- code: "CONTRACT_INPUTS_MISMATCH",
2013
+ code: issue.code ?? "CONTRACT_INPUTS_MISMATCH",
1969
2014
  source: SOURCE,
1970
- message: `${m.kind}/${stepName}: inputs at '${issue.path}' do not satisfy ${issue.targetLabel}'s declared inputType: ${issue.message}`,
2015
+ message: issue.code
2016
+ ? `${m.kind}/${stepName}: inputs at '${issue.path}' flow into ${issue.targetLabel} with disagreeing type arguments: ${issue.message}`
2017
+ : `${m.kind}/${stepName}: inputs at '${issue.path}' do not satisfy ${issue.targetLabel}'s declared inputType: ${issue.message}`,
1971
2018
  data: {
1972
2019
  resource: { kind: m.kind, name: stepName ?? "" },
1973
2020
  filePath: stepFile,
@@ -2260,6 +2307,30 @@ export class StaticAnalyzer {
2260
2307
  byPath.set(path, result.type);
2261
2308
  }
2262
2309
 
2310
+ // The producer half of the type-argument check. A CEL type is a bare
2311
+ // name — cel-js types by constructor identity, so a byte stream and a
2312
+ // string stream are one CEL type and always will be — so the argument
2313
+ // comparison is a parallel pass over the SCHEMAS the analyzer already
2314
+ // walks. This is where the producer's schema is in hand: navigating
2315
+ // the expression's chain into its context schema is exactly "the
2316
+ // producer's outputType at the expression's tail".
2317
+ //
2318
+ // Only a PLAIN CHAIN is navigated — `steps.encode.result.output`, the
2319
+ // shape a wiring site actually takes. An expression that computes
2320
+ // rather than names has no schema to read off the context, so it
2321
+ // records nothing and no argument check fires: silence where the
2322
+ // analyzer knows least is the conservative direction, and the same one
2323
+ // `x-telo-step-context`'s pure-`value` typing takes.
2324
+ const chain = effectiveContext ? plainChainOf(`\${{${expr}}}`) : undefined;
2325
+ if (chain) {
2326
+ const produced = navigateSchemaToExprPath(effectiveContext!, chain);
2327
+ if (produced) {
2328
+ let byPath = celSourceSchemaByPath.get(m);
2329
+ if (!byPath) celSourceSchemaByPath.set(m, (byPath = new Map()));
2330
+ byPath.set(path, produced);
2331
+ }
2332
+ }
2333
+
2263
2334
  // A non-deterministic call in a compile-eval field is baked once at
2264
2335
  // load: `nowIso()` there freezes at boot. Sometimes that is the
2265
2336
  // intent (a boot timestamp, a run id), so it warns rather than
@@ -2331,13 +2402,35 @@ export class StaticAnalyzer {
2331
2402
  for (const slot of celReturnSlots) {
2332
2403
  const type = celTypeByPath.get(slot.manifest)?.get(slot.path);
2333
2404
  if (type === undefined) continue;
2334
- if (celTypeSatisfiesJsonSchema(type.split("<")[0]!, slot.schema)) continue;
2335
- const expected = slot.schema["x-telo-type"] ?? slot.schema.type ?? "unknown";
2405
+ if (!celTypeSatisfiesJsonSchema(type.split("<")[0]!, slot.schema)) {
2406
+ const expected = slot.schema["x-telo-type"] ?? slot.schema.type ?? "unknown";
2407
+ diagnostics.push({
2408
+ severity: DiagnosticSeverity.Error,
2409
+ code: "CEL_TYPE_ERROR",
2410
+ source: SOURCE,
2411
+ message: `${slot.resource.kind}/${slot.resource.name}: CEL at '${slot.path}' returns '${type}' but the field expects '${expected}'.`,
2412
+ data: { resource: slot.resource, filePath: slot.filePath, path: slot.path },
2413
+ });
2414
+ continue;
2415
+ }
2416
+ // The type fits; do its ARGUMENTS agree? Covariant and gradual — an
2417
+ // omitted argument is *any* in both directions, so an unmigrated producer
2418
+ // or consumer is never reported, and only a definite conflict is.
2419
+ const produced = celSourceSchemaByPath.get(slot.manifest)?.get(slot.path);
2420
+ if (!produced) continue;
2421
+ const { compatible, issues } = checkSchemaCompatibility(
2422
+ produced,
2423
+ slot.schema,
2424
+ (ref: string) => defs.schemaForId(ref),
2425
+ );
2426
+ if (compatible) continue;
2336
2427
  diagnostics.push({
2337
2428
  severity: DiagnosticSeverity.Error,
2338
- code: "CEL_TYPE_ERROR",
2429
+ code: "CEL_TYPE_ARGUMENT_MISMATCH",
2339
2430
  source: SOURCE,
2340
- message: `${slot.resource.kind}/${slot.resource.name}: CEL at '${slot.path}' returns '${type}' but the field expects '${expected}'.`,
2431
+ message:
2432
+ `${slot.resource.kind}/${slot.resource.name}: CEL at '${slot.path}' produces a value ` +
2433
+ `whose type arguments disagree with the field's: ${issues.join("; ")}.`,
2341
2434
  data: { resource: slot.resource, filePath: slot.filePath, path: slot.path },
2342
2435
  });
2343
2436
  }
@@ -3,10 +3,13 @@ import type { ResourceManifest } from "@telorun/sdk";
3
3
  import { jsonSchemaToCelType, VALUE_BRAND_BASE } from "./schema-compat.js";
4
4
 
5
5
  /** Transport protocol on a `ports` entry → the nominal CEL brand its resolved
6
- * value carries. Mirrors the `protocol` enum in the Application schema. */
6
+ * value carries. Mirrors the `protocol` enum in the Application schema, and
7
+ * names the value types by their canonical `Telo.`-qualified names — the same
8
+ * spelling an author writes at `x-telo-type`, so a branded port and a branded
9
+ * field are comparable by name with nothing in between to translate. */
7
10
  const PORT_PROTOCOL_BRAND: Record<string, string> = {
8
- tcp: "TcpPort",
9
- udp: "UdpPort",
11
+ tcp: "Telo.TcpPort",
12
+ udp: "Telo.UdpPort",
10
13
  };
11
14
 
12
15
  export { buildCelEnvironment } from "@telorun/templating";
@@ -127,6 +127,16 @@ export class DefinitionRegistry {
127
127
  return this.registeredSchemaIds.has(id) || this.ajv.getSchema(id) !== undefined;
128
128
  }
129
129
 
130
+ /** The schema registered under `id`, for a structural comparison that must see
131
+ * THROUGH a named shape. Declaring a shape once and referencing it is the
132
+ * sanctioned way to reuse one, so a comparator that cannot follow the
133
+ * reference judges two opaque nodes and learns nothing. */
134
+ schemaForId(id: string): Record<string, any> | undefined {
135
+ const compiled = this.ajv.getSchema(id);
136
+ const schema = compiled?.schema;
137
+ return schema && typeof schema === "object" ? (schema as Record<string, any>) : undefined;
138
+ }
139
+
130
140
  /** Validates data against a schema using this registry's AJV instance, which has all
131
141
  * registered definition schemas loaded — enabling cross-module $ref resolution.
132
142
  * A compile failure returns `[]` here; it is surfaced loudly (once, on the
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 {
@@ -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,