@telorun/analyzer 0.62.0 → 0.63.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 (146) hide show
  1. package/dist/analysis-registry.d.ts.map +1 -1
  2. package/dist/analysis-registry.js +6 -0
  3. package/dist/analyzer.d.ts +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/analyzer.js +343 -13
  6. package/dist/call-graph.d.ts.map +1 -1
  7. package/dist/call-graph.js +2 -7
  8. package/dist/flatten-for-analyzer.js +1 -1
  9. package/dist/import-resolution-diagnostics.d.ts +9 -2
  10. package/dist/import-resolution-diagnostics.d.ts.map +1 -1
  11. package/dist/import-resolution-diagnostics.js +26 -7
  12. package/dist/index.d.ts +15 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +13 -1
  15. package/dist/invocation-contract.d.ts +6 -1
  16. package/dist/invocation-contract.d.ts.map +1 -1
  17. package/dist/invocation-contract.js +27 -4
  18. package/dist/loaded-types.d.ts +12 -3
  19. package/dist/loaded-types.d.ts.map +1 -1
  20. package/dist/manifest-loader.d.ts +0 -1
  21. package/dist/manifest-loader.d.ts.map +1 -1
  22. package/dist/manifest-loader.js +62 -41
  23. package/dist/manifest-schemas.d.ts +699 -0
  24. package/dist/manifest-schemas.d.ts.map +1 -1
  25. package/dist/manifest-schemas.js +306 -14
  26. package/dist/module-compatibility.d.ts +40 -0
  27. package/dist/module-compatibility.d.ts.map +1 -0
  28. package/dist/module-compatibility.js +52 -0
  29. package/dist/ref-slot-reading.d.ts +30 -0
  30. package/dist/ref-slot-reading.d.ts.map +1 -0
  31. package/dist/ref-slot-reading.js +139 -0
  32. package/dist/referrer-rule.d.ts +67 -0
  33. package/dist/referrer-rule.d.ts.map +1 -0
  34. package/dist/referrer-rule.js +96 -0
  35. package/dist/release/index.d.ts +1 -1
  36. package/dist/release/index.d.ts.map +1 -1
  37. package/dist/release/index.js +1 -1
  38. package/dist/release/payload-digest.d.ts +7 -3
  39. package/dist/release/payload-digest.d.ts.map +1 -1
  40. package/dist/release/payload-digest.js +7 -3
  41. package/dist/release/version-stamp.d.ts +16 -0
  42. package/dist/release/version-stamp.d.ts.map +1 -1
  43. package/dist/release/version-stamp.js +52 -0
  44. package/dist/release/workspace-config.d.ts +10 -6
  45. package/dist/release/workspace-config.d.ts.map +1 -1
  46. package/dist/release/workspace-config.js +10 -6
  47. package/dist/resolve-schema-ref-kinds.d.ts +4 -3
  48. package/dist/resolve-schema-ref-kinds.d.ts.map +1 -1
  49. package/dist/resolve-schema-ref-kinds.js +16 -0
  50. package/dist/resolve-throws-union.d.ts.map +1 -1
  51. package/dist/resolve-throws-union.js +4 -3
  52. package/dist/resolve-zone-containment.d.ts +99 -0
  53. package/dist/resolve-zone-containment.d.ts.map +1 -0
  54. package/dist/resolve-zone-containment.js +212 -0
  55. package/dist/resolve-zone-requirements.d.ts +5 -0
  56. package/dist/resolve-zone-requirements.d.ts.map +1 -1
  57. package/dist/resolve-zone-requirements.js +48 -7
  58. package/dist/resource-rule.d.ts +106 -0
  59. package/dist/resource-rule.d.ts.map +1 -0
  60. package/dist/resource-rule.js +236 -0
  61. package/dist/rule-condition.d.ts +50 -0
  62. package/dist/rule-condition.d.ts.map +1 -0
  63. package/dist/rule-condition.js +108 -0
  64. package/dist/schema-keywords.d.ts.map +1 -1
  65. package/dist/schema-keywords.js +20 -0
  66. package/dist/schema-projection.d.ts +171 -0
  67. package/dist/schema-projection.d.ts.map +1 -0
  68. package/dist/schema-projection.js +317 -0
  69. package/dist/step-slot.d.ts +39 -0
  70. package/dist/step-slot.d.ts.map +1 -0
  71. package/dist/step-slot.js +49 -0
  72. package/dist/telo-version.d.ts +1 -1
  73. package/dist/telo-version.js +1 -1
  74. package/dist/validate-cel-context.d.ts.map +1 -1
  75. package/dist/validate-cel-context.js +17 -3
  76. package/dist/validate-durable-regions.d.ts +15 -0
  77. package/dist/validate-durable-regions.d.ts.map +1 -0
  78. package/dist/validate-durable-regions.js +336 -0
  79. package/dist/validate-references.d.ts.map +1 -1
  80. package/dist/validate-references.js +44 -2
  81. package/dist/validate-referrer-rules.d.ts +120 -0
  82. package/dist/validate-referrer-rules.d.ts.map +1 -0
  83. package/dist/validate-referrer-rules.js +255 -0
  84. package/dist/validate-resource-rules.d.ts +114 -0
  85. package/dist/validate-resource-rules.d.ts.map +1 -0
  86. package/dist/validate-resource-rules.js +283 -0
  87. package/dist/validate-schema-projection.d.ts +32 -0
  88. package/dist/validate-schema-projection.d.ts.map +1 -0
  89. package/dist/validate-schema-projection.js +110 -0
  90. package/dist/validate-step-inputs.d.ts +2 -1
  91. package/dist/validate-step-inputs.d.ts.map +1 -1
  92. package/dist/validate-step-inputs.js +5 -3
  93. package/dist/validate-throws-coverage.d.ts.map +1 -1
  94. package/dist/validate-throws-coverage.js +11 -5
  95. package/dist/validate-zone-slots.d.ts +2 -2
  96. package/dist/validate-zone-slots.d.ts.map +1 -1
  97. package/dist/validate-zone-slots.js +202 -6
  98. package/dist/validate-zone-violations.d.ts +12 -0
  99. package/dist/validate-zone-violations.d.ts.map +1 -0
  100. package/dist/validate-zone-violations.js +169 -0
  101. package/dist/value-type-keyword.d.ts +1 -1
  102. package/dist/value-type-keyword.d.ts.map +1 -1
  103. package/dist/value-type-keyword.js +4 -0
  104. package/dist/zone-slot.d.ts +61 -0
  105. package/dist/zone-slot.d.ts.map +1 -1
  106. package/dist/zone-slot.js +86 -5
  107. package/package.json +2 -2
  108. package/src/analysis-registry.ts +6 -0
  109. package/src/analyzer.ts +395 -14
  110. package/src/call-graph.ts +2 -8
  111. package/src/flatten-for-analyzer.ts +1 -1
  112. package/src/import-resolution-diagnostics.ts +25 -7
  113. package/src/index.ts +73 -0
  114. package/src/invocation-contract.ts +45 -2
  115. package/src/loaded-types.ts +12 -3
  116. package/src/manifest-loader.ts +87 -55
  117. package/src/manifest-schemas.ts +328 -14
  118. package/src/module-compatibility.ts +72 -0
  119. package/src/ref-slot-reading.ts +156 -0
  120. package/src/referrer-rule.ts +116 -0
  121. package/src/release/index.ts +1 -0
  122. package/src/release/payload-digest.ts +7 -3
  123. package/src/release/version-stamp.ts +58 -0
  124. package/src/release/workspace-config.ts +10 -6
  125. package/src/resolve-schema-ref-kinds.ts +28 -4
  126. package/src/resolve-throws-union.ts +4 -3
  127. package/src/resolve-zone-containment.ts +329 -0
  128. package/src/resolve-zone-requirements.ts +67 -7
  129. package/src/resource-rule.ts +252 -0
  130. package/src/rule-condition.ts +126 -0
  131. package/src/schema-keywords.ts +24 -0
  132. package/src/schema-projection.ts +403 -0
  133. package/src/step-slot.ts +64 -0
  134. package/src/telo-version.ts +1 -1
  135. package/src/validate-cel-context.ts +17 -3
  136. package/src/validate-durable-regions.ts +401 -0
  137. package/src/validate-references.ts +57 -2
  138. package/src/validate-referrer-rules.ts +373 -0
  139. package/src/validate-resource-rules.ts +401 -0
  140. package/src/validate-schema-projection.ts +184 -0
  141. package/src/validate-step-inputs.ts +5 -3
  142. package/src/validate-throws-coverage.ts +12 -5
  143. package/src/validate-zone-slots.ts +192 -8
  144. package/src/validate-zone-violations.ts +190 -0
  145. package/src/value-type-keyword.ts +4 -0
  146. package/src/zone-slot.ts +114 -5
@@ -246,6 +246,39 @@ export const InvokeStepSchema = {
246
246
  retry: {
247
247
  title: "Retry",
248
248
  $ref: `${MANIFEST_SCHEMA_URI}#/$defs/RetryPolicy`,
249
+ // A sibling of the shared policy rather than a member of it, the way
250
+ // `Http.Request.retry` adds `honorRetryAfter` — fragment expansion merges
251
+ // siblings, so the policy stays closed and each consumer's surface stays
252
+ // honest. `nonRetryable` matches an ERROR CODE, which is what a step's
253
+ // failures carry; an HTTP retry classifies on a response STATUS and has
254
+ // `retryOn` for it, so putting this on the shared shape would advertise a
255
+ // field there that nothing could read.
256
+ properties: {
257
+ nonRetryable: {
258
+ title: "Non-retryable codes",
259
+ description:
260
+ "Error codes that end the loop immediately instead of consuming the budget. " +
261
+ "The built-in exclusions are the ones decidable without judgement — a " +
262
+ "cancellation, and the kernel's verdicts on the shape of the call; whether a " +
263
+ "DOMAIN failure is worth re-attempting only the author knows, and without " +
264
+ "this every terminal one is retried to exhaustion — for a non-idempotent " +
265
+ "target, N extra attempts at a side effect.",
266
+ type: "array",
267
+ items: { type: "string" },
268
+ default: [],
269
+ },
270
+ },
271
+ },
272
+ timeout: {
273
+ title: "Timeout",
274
+ description:
275
+ "How long ONE attempt may take, in milliseconds; on elapse the dispatch is " +
276
+ "cancelled and the step fails ERR_STEP_TIMEOUT. Per attempt rather than for " +
277
+ "the whole retry loop, so what a single call is allowed to take does not " +
278
+ "depend on how slow the earlier attempts were. It belongs to the step rather " +
279
+ "than the target because the target does not know who is waiting.",
280
+ type: "integer",
281
+ minimum: 1,
249
282
  },
250
283
  },
251
284
  };
@@ -306,12 +339,241 @@ export const KindSchemaSchema = {
306
339
  additionalProperties: true,
307
340
  };
308
341
 
342
+
343
+ /** The `error` variable a `catch:` / `finally:` branch binds. One object, used
344
+ * twice with different nullability — `finally` runs on the success path too. */
345
+ const stepErrorProperties = {
346
+ code: {
347
+ description: "Structured error code; INTERNAL_ERROR for plain errors.",
348
+ type: "string",
349
+ },
350
+ message: { description: "Human-readable error message.", type: "string" },
351
+ step: { description: "Name of the step that threw.", type: "string" },
352
+ data: { description: "Structured payload from an InvokeError, else undefined." },
353
+ };
354
+
355
+ /** A step list's own items, written as the self-reference the localizer leaves
356
+ * behind. A recursive fragment states its self-references ALREADY localized:
357
+ * `hoistFragmentDef` copies the body verbatim into the consuming schema's
358
+ * `$defs`, so a foreign `telo://manifest#/…` written here would survive into a
359
+ * document the editor's local-only resolver throws on. */
360
+ const stepList = (title: string, description: string) => ({
361
+ title,
362
+ description,
363
+ "x-telo-topology-role": "branch",
364
+ type: "array",
365
+ items: { $ref: `#/$defs/${hoistedDefKey("Step")}` },
366
+ });
367
+
368
+ /**
369
+ * One step of a step body: a dispatch, a computed value, or a control-flow block.
370
+ *
371
+ * THE GRAMMAR, owned here rather than by `modules/run`. It was declared four
372
+ * times in one file — `$defs` are local to the schema that declares them, so
373
+ * four kinds in one module could not share one, let alone four kinds in four
374
+ * modules — and that is why a kind wanting a body had to take a `!ref` to an
375
+ * executable instead. A fragment is what makes `Sql.Transaction` or a durable
376
+ * `Workflow` able to carry one directly.
377
+ *
378
+ * RECURSIVE (a branch holds steps), so a reference to it is localized and
379
+ * hoisted rather than expanded in place — see {@link localizeRecursiveFragment}.
380
+ *
381
+ * `while/do` is admitted in EVERY body. Three of the four copies dropped it
382
+ * ("the kind is itself the loop"), which is an editorial nudge rather than a
383
+ * soundness rule — a nested `while` inside a for-each body is ordinary control
384
+ * flow the same engine already runs — and a fragment cannot be narrowed by its
385
+ * consumer anyway: draft-07 makes `$ref` exclusive, so a sibling restating a
386
+ * subset would be silently ignored by AJV while still reaching completion. One
387
+ * grammar, or two fragments and the duplication back where it started.
388
+ */
389
+ export const StepSchema = {
390
+ title: "Step",
391
+ description: "Single executable step or control-flow block.",
392
+ type: "object",
393
+ properties: {
394
+ name: { title: "Name", description: "Unique step name.", type: "string" },
395
+ },
396
+ oneOf: [
397
+ // THE dispatch site, shared with an Application's `targets:` rather than
398
+ // restated. `name` is hoisted to the enclosing step object above, which is
399
+ // why this branch carries only what a dispatch itself needs.
400
+ {
401
+ title: "invoke",
402
+ description: "Invokes an invocable or runnable resource.",
403
+ $ref: `${MANIFEST_SCHEMA_URI}#/$defs/InvokeStep`,
404
+ },
405
+ {
406
+ title: "if/then/else",
407
+ description: "Conditional branch; executes then or else based on a boolean expression.",
408
+ properties: {
409
+ if: {
410
+ title: "If",
411
+ description: "CEL boolean expression; true executes then, false executes else.",
412
+ "x-telo-topology-role": "predicate",
413
+ type: "boolean",
414
+ },
415
+ elseif: {
416
+ title: "Else If",
417
+ description: "Additional condition-branch pairs evaluated when if is false.",
418
+ "x-telo-topology-role": "branch-list",
419
+ type: "array",
420
+ items: {
421
+ type: "object",
422
+ properties: {
423
+ if: {
424
+ title: "If",
425
+ description: "CEL boolean expression for this else-if branch.",
426
+ "x-telo-topology-role": "predicate",
427
+ type: "boolean",
428
+ },
429
+ then: stepList("Then", "Steps executed when this else-if condition is true."),
430
+ },
431
+ required: ["if", "then"],
432
+ },
433
+ },
434
+ then: stepList("Then", "Steps executed when if evaluates true."),
435
+ else: stepList(
436
+ "Else",
437
+ "Steps executed when if (and all elseif conditions) evaluate false.",
438
+ ),
439
+ },
440
+ required: ["if", "then"],
441
+ },
442
+ {
443
+ title: "while/do",
444
+ description: "Loop; executes do steps repeatedly while condition is true.",
445
+ properties: {
446
+ while: {
447
+ title: "While",
448
+ description: "CEL boolean expression; evaluated before each iteration.",
449
+ "x-telo-topology-role": "predicate",
450
+ type: "boolean",
451
+ },
452
+ do: stepList("Do", "Steps executed on each iteration."),
453
+ },
454
+ required: ["while", "do"],
455
+ },
456
+ {
457
+ title: "switch/cases/default",
458
+ description: "Multi-branch dispatch; matches a value expression against case keys.",
459
+ properties: {
460
+ switch: {
461
+ title: "Switch",
462
+ description: "CEL expression; result matched against case keys.",
463
+ "x-telo-topology-role": "discriminator",
464
+ type: "string",
465
+ },
466
+ cases: {
467
+ title: "Cases",
468
+ description: "Map of value to step list; executed when switch matches the key.",
469
+ "x-telo-topology-role": "case-map",
470
+ type: "object",
471
+ additionalProperties: {
472
+ type: "array",
473
+ items: { $ref: `#/$defs/${hoistedDefKey("Step")}` },
474
+ },
475
+ },
476
+ default: stepList("Default", "Steps executed when no case matches."),
477
+ },
478
+ required: ["switch", "cases"],
479
+ },
480
+ {
481
+ title: "try/catch/finally",
482
+ description:
483
+ "Error boundary; executes try steps and handles failure via catch and finally.",
484
+ properties: {
485
+ try: stepList("Try", "Steps executed; halts on first failure and jumps to catch."),
486
+ catch: {
487
+ ...stepList("Catch", "Steps executed when try fails; receives error context."),
488
+ "x-telo-error-context": {
489
+ type: "object",
490
+ description: "The caught failure (always present inside catch).",
491
+ properties: stepErrorProperties,
492
+ additionalProperties: false,
493
+ },
494
+ },
495
+ finally: {
496
+ ...stepList(
497
+ "Finally",
498
+ "Steps always executed after try/catch regardless of outcome.",
499
+ ),
500
+ // error is null on the success path and the caught failure when a
501
+ // try/catch failure propagates. Typed nullable so the analyzer flags
502
+ // unguarded field access (CEL_NULLABLE_ACCESS).
503
+ "x-telo-error-context": {
504
+ type: ["object", "null"],
505
+ description: "The caught failure, or null when try (and catch) succeeded.",
506
+ properties: stepErrorProperties,
507
+ additionalProperties: false,
508
+ },
509
+ },
510
+ },
511
+ required: ["try"],
512
+ },
513
+ {
514
+ title: "throw",
515
+ description:
516
+ "Throws an InvokeError unconditionally. Works inside catch blocks via CEL " +
517
+ '(e.g. code set to `!cel "error.code"`).',
518
+ properties: {
519
+ throw: {
520
+ title: "Throw",
521
+ description: "InvokeError descriptor; code is required, message/data optional.",
522
+ type: "object",
523
+ properties: {
524
+ code: {
525
+ title: "Code",
526
+ description: "Structured error code; uppercase SNAKE_CASE by convention.",
527
+ type: "string",
528
+ },
529
+ message: {
530
+ title: "Message",
531
+ description: "Human-readable error message. Defaults to `code` when omitted.",
532
+ type: "string",
533
+ },
534
+ data: {
535
+ title: "Data",
536
+ description: "Optional structured payload attached to the error.",
537
+ },
538
+ },
539
+ required: ["code"],
540
+ },
541
+ },
542
+ required: ["throw"],
543
+ },
544
+ {
545
+ title: "value",
546
+ description:
547
+ "Computes a value and publishes it as `steps.<name>.result`, with no dispatch — " +
548
+ "no resource, no span, no topology node. For an intermediate derived from an " +
549
+ "earlier step: reshape a response, total some rows, build the next call's inputs.",
550
+ properties: {
551
+ value: {
552
+ title: "Value",
553
+ description:
554
+ "A CEL expression — or a structure (map / array) with `!cel` leaves — " +
555
+ "evaluated in the step scope: `inputs`, the results of the steps before it, " +
556
+ "and whatever the enclosing kind binds (`item` / `index` / `items`, " +
557
+ "`iteration` / `previous`).",
558
+ },
559
+ },
560
+ required: ["value"],
561
+ },
562
+ ],
563
+ required: ["name"],
564
+ };
565
+
566
+ /** The fragments that CONTAIN THEMSELVES — a schema's properties hold schemas, a
567
+ * step's branches hold steps. A reference to one is localized and hoisted
568
+ * rather than expanded in place ({@link localizeRecursiveFragment}), because
569
+ * inlining cannot terminate. */
570
+ const RECURSIVE_FRAGMENTS = new Set(["JsonSchema7", "KindSchema", "Step"]);
571
+
309
572
  /** The fragments that describe author-written JSON Schema, whichever vocabulary
310
- * they admit. They are also exactly the RECURSIVE ones — a schema is the only
311
- * shape in this set that contains itself so a reference to one is localized
312
- * and hoisted rather than expanded in place ({@link localizeRecursiveFragment});
313
- * should a self-containing fragment that is not a schema ever land, the two
314
- * ideas split and this set stays the one about schemas. */
573
+ * they admit. Split from {@link RECURSIVE_FRAGMENTS} when `Step` landed: being
574
+ * recursive is a fact about how a reference is RESOLVED, being a schema is a
575
+ * fact about which vocabulary a slot admits, and `Step` is the first fragment
576
+ * that is one without the other. */
315
577
  const SCHEMA_FRAGMENTS = new Set(["JsonSchema7", "KindSchema"]);
316
578
 
317
579
  /** True when a slot's `x-telo-fragment` stamp says it holds author-written JSON
@@ -362,6 +624,7 @@ export const ManifestRootSchema = {
362
624
  RetryPolicy: RetryPolicySchema,
363
625
  RetryAttempts: RetryAttemptsSchema,
364
626
  InvokeStep: InvokeStepSchema,
627
+ Step: StepSchema,
365
628
  JsonSchema7: JsonSchema7Schema,
366
629
  KindSchema: KindSchemaSchema,
367
630
  },
@@ -369,16 +632,62 @@ export const ManifestRootSchema = {
369
632
 
370
633
  deepFreeze(ManifestRootSchema);
371
634
 
635
+ /**
636
+ * One expanded template per fragment, cloned per consumer.
637
+ *
638
+ * Expansion is a full walk of the body and the result is identical every time —
639
+ * `Step` alone is a couple of hundred nodes and `modules/run` hoists it four
640
+ * times, on the kernel's boot path. The copy handed out is still fresh, because
641
+ * downstream passes (`resolveSchemaRefKinds`, migrations) rewrite schemas in
642
+ * place.
643
+ */
644
+ const expandedFragments = new Map<string, Record<string, unknown>>();
645
+
646
+ /**
647
+ * A fresh, fully expanded copy of a fragment body.
648
+ *
649
+ * TARGET-INDEPENDENT, which is what makes caching it correct: the walk is given
650
+ * a scratch hoist target and REFUSED if anything lands in it. A localized copy
651
+ * belongs in the schema a validator compiles, never inside another `$defs`
652
+ * entry, so a body that needed one could not have a single cached form — the
653
+ * first consumer would receive the entry and every later one would silently get
654
+ * a pointer to nothing. Nothing exercises that today: a recursive fragment
655
+ * writes its own self-references already localized, and a cross-reference
656
+ * between two recursive fragments is hoisted at the top level by
657
+ * {@link hoistFragmentDef}'s `pending` loop. If one ever does, this throws at
658
+ * the write instead of resolving by accident.
659
+ */
660
+ function expandedFragment(name: string): Record<string, unknown> {
661
+ let template = expandedFragments.get(name);
662
+ if (!template) {
663
+ const fragment = (ManifestRootSchema.$defs as Record<string, unknown>)[name];
664
+ template = structuredClone(fragment) as Record<string, unknown>;
665
+ const scratch: Record<string, unknown> = {};
666
+ walkFragments(template, new Set(), { hoistTarget: scratch, depth: 1 });
667
+ if (scratch.$defs !== undefined) {
668
+ throw new Error(
669
+ `Manifest fragment '${name}' references a recursive fragment from inside its body. ` +
670
+ `Its expanded form is no longer independent of where it is hoisted — localize the ` +
671
+ `reference in the fragment source (write '#/$defs/telo:<Name>' directly) or teach ` +
672
+ `expandedFragment to take the hoist target.`,
673
+ );
674
+ }
675
+ expandedFragments.set(name, template);
676
+ }
677
+ return structuredClone(template);
678
+ }
679
+
372
680
  /** A private, expanded copy of a fragment, for a consumer that must EMBED one
373
681
  * rather than `$ref` it — `builtins.ts` is not a manifest and never passes
374
682
  * through the loader, so its dispatch site has to arrive already resolved and
375
683
  * already stamped. Cloned for the reason {@link deepFreeze} explains. */
684
+
376
685
  export function manifestFragment(name: string): Record<string, unknown> {
377
686
  const fragment = (ManifestRootSchema.$defs as Record<string, unknown>)[name];
378
687
  if (!fragment || typeof fragment !== "object") {
379
688
  throw new Error(`Unknown manifest fragment '${name}'`);
380
689
  }
381
- if (SCHEMA_FRAGMENTS.has(name)) {
690
+ if (RECURSIVE_FRAGMENTS.has(name)) {
382
691
  // A recursive fragment has no expanded form — that is the whole reason it is
383
692
  // localized instead. Embedding one would hand the consumer a body whose
384
693
  // `#/$defs` pointers resolve against nothing.
@@ -388,8 +697,7 @@ export function manifestFragment(name: string): Record<string, unknown> {
388
697
  `enclosing schema through withSchemaFragments().`,
389
698
  );
390
699
  }
391
- const copy = structuredClone(fragment) as Record<string, unknown>;
392
- expandManifestFragments(copy);
700
+ const copy = expandedFragment(name);
393
701
  copy[X_TELO_FRAGMENT] = name;
394
702
  return copy;
395
703
  }
@@ -500,7 +808,7 @@ function localizeRecursiveFragment(
500
808
  const ref = value.$ref;
501
809
  if (typeof ref !== "string" || !ref.startsWith(FRAGMENT_PREFIX)) return undefined;
502
810
  const name = ref.slice(FRAGMENT_PREFIX.length);
503
- if (!SCHEMA_FRAGMENTS.has(name)) return undefined;
811
+ if (!RECURSIVE_FRAGMENTS.has(name)) return undefined;
504
812
 
505
813
  hoistFragmentDef(ctx.hoistTarget, name);
506
814
  return { ...value, $ref: `#/$defs/${hoistedDefKey(name)}`, [X_TELO_FRAGMENT]: name };
@@ -522,8 +830,15 @@ function hoistFragmentDef(target: Record<string, unknown>, name: string): void {
522
830
  // fragment reached through its own self-reference, with no slot in sight.
523
831
  // That node is exactly where an annotation like `x-telo-eval` is written,
524
832
  // so it is where the stamp has to be readable.
525
- defs[key] = { ...(structuredClone(fragment) as object), [X_TELO_FRAGMENT]: next };
526
- for (const nested of SCHEMA_FRAGMENTS) {
833
+ // A hoisted body may point at a NON-recursive fragment (`Step`'s dispatch
834
+ // branch is `InvokeStep`), and a copy left unexpanded carries a foreign
835
+ // `telo://manifest#/…` reference into a document the editor's resolver
836
+ // throws on — the same failure gating fragment expansion once caused. Where
837
+ // a nested LOCALIZED copy would belong is not this body; see
838
+ // {@link expandedFragment}, which refuses that case rather than guessing.
839
+ const body = expandedFragment(next);
840
+ defs[key] = { ...body, [X_TELO_FRAGMENT]: next };
841
+ for (const nested of RECURSIVE_FRAGMENTS) {
527
842
  if (nested !== next && referencesLocalDef(defs[key], nested)) pending.push(nested);
528
843
  }
529
844
  }
@@ -589,10 +904,9 @@ function fragmentFor(value: unknown): Record<string, unknown> | undefined {
589
904
  const fragment = (ManifestRootSchema.$defs as Record<string, unknown>)[name];
590
905
  if (!fragment || typeof fragment !== "object") return undefined;
591
906
 
592
- const expanded = structuredClone(fragment) as Record<string, unknown>;
593
907
  // A fragment may reference another (InvokeStep holds a RetryPolicy); the copy
594
- // is expanded too, so one pass leaves no reference behind.
595
- expandManifestFragments(expanded);
908
+ // arrives expanded, so one pass leaves no reference behind.
909
+ const expanded = expandedFragment(name);
596
910
 
597
911
  for (const [key, own] of Object.entries(node)) {
598
912
  if (key === "$ref") continue;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Can this runtime host that module version? One question, one answer, one
3
+ * implementation — the CLI's `upgrade` and every IDE's upgrade affordance all
4
+ * ask it of a candidate version's `telo.yaml` text.
5
+ *
6
+ * It lives beside {@link readRequires} because the `requires:` grammar has
7
+ * exactly one reader by rule, and the moment a second host learned to filter
8
+ * candidates by compatibility that rule needed a shared verdict, not a second
9
+ * parser. Browser-safe: text in, verdict out, no transport and no filesystem —
10
+ * fetching the candidate manifest is the caller's job, since only the caller
11
+ * knows which transport (or cache) addresses it.
12
+ */
13
+
14
+ import { defaultCustomTags } from "@telorun/templating";
15
+ import { parseAllDocuments } from "yaml";
16
+ import { isModuleKind } from "./module-kinds.js";
17
+ import { evaluateRequires, readRequires, type HostVersions } from "./requires-block.js";
18
+
19
+ /**
20
+ * How a candidate version answered the compatibility question.
21
+ *
22
+ * `unknown` — the manifest could not be read or names no module document — is
23
+ * never treated as incompatible, since an unreachable registry must not
24
+ * silently freeze a consumer's imports.
25
+ *
26
+ * The two rejecting answers are kept APART because they call for different
27
+ * actions and the user is told which one applies: `too-new` is fixed by
28
+ * upgrading telo, `unreadable` cannot be fixed by the consumer at all.
29
+ * Collapsing them into one "no" and then printing "requires a newer telo" would
30
+ * assert a cause the check never established, and point at a runtime upgrade
31
+ * that will not help.
32
+ */
33
+ export type ModuleCompatibility = "yes" | "too-new" | "unreadable" | "unknown";
34
+
35
+ /**
36
+ * Read a module manifest's declared `requires:` and decide whether the runtime
37
+ * described by `teloVersion` / `host` can host it.
38
+ *
39
+ * A module that declares nothing is compatible — the bootstrap rule, permanent
40
+ * for everything published before the mechanism existed. A host that reports no
41
+ * version for an axis skips it rather than guessing, which is exactly the
42
+ * editor case: an IDE is not the machine that will run the manifest, so it
43
+ * speaks for the telo surface and for nothing else.
44
+ */
45
+ export function manifestCompatibility(
46
+ manifestText: string,
47
+ teloVersion: string | undefined,
48
+ host: HostVersions = {},
49
+ ): ModuleCompatibility {
50
+ let doc: Record<string, unknown> | undefined;
51
+ try {
52
+ for (const parsed of parseAllDocuments(manifestText, { customTags: defaultCustomTags() })) {
53
+ const value = parsed.toJS() as Record<string, unknown> | null;
54
+ const kind = value?.kind;
55
+ if (typeof kind === "string" && isModuleKind(kind)) {
56
+ doc = value as Record<string, unknown>;
57
+ break;
58
+ }
59
+ }
60
+ } catch {
61
+ return "unknown";
62
+ }
63
+ if (!doc) return "unknown";
64
+
65
+ const { block, issues } = readRequires(doc);
66
+ // A malformed declaration is not a licence to install: the module claims a
67
+ // requirement it failed to state, and guessing which way it pointed is how a
68
+ // consumer ends up on a version that cannot load. The load gate warns about
69
+ // this same manifest, so the two halves agree.
70
+ if (issues.some((i) => !i.unknownAxis)) return "unreadable";
71
+ return evaluateRequires(block, teloVersion, host).satisfied ? "yes" : "too-new";
72
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * What a `self.<refSlot>` READS as.
3
+ *
4
+ * A ref slot holds the live instance after Phase-5 injection, and the kernel
5
+ * substitutes each one with its last published reading before evaluating a
6
+ * template body (`celSelfView`) — so `self.table.name` and `resources.users.name`
7
+ * name the same fact. This is the static half of that equivalence: without it a
8
+ * ref slot's schema is whatever the kind wrote at the slot (a title, a
9
+ * description and the annotation), which types nothing at all.
10
+ *
11
+ * A published reading has two halves and they are typed differently, exactly as
12
+ * `resources.<name>` is:
13
+ *
14
+ * - the FLAT half is what `snapshot()` returned, which no manifest declares, so
15
+ * it stays open. Closing it would reject reads that are correct today, and
16
+ * there is nothing to close it against.
17
+ * - `status` is DECLARED (`status:` on the kind, folded along `extends`), so it
18
+ * is typed and a typo below it is `CEL_UNKNOWN_FIELD`.
19
+ *
20
+ * Deliberately no attempt to type the flat half from the kind's own `schema:`:
21
+ * a snapshot is what the controller chose to publish, not the config it was
22
+ * given, and the two coincide only by convention. Typing one as the other would
23
+ * invent errors on a kind that publishes anything else.
24
+ *
25
+ * Browser-safe: no Node built-ins.
26
+ */
27
+ import { effectiveStatusSchema } from "./extends-resolution.js";
28
+ import { isRefSlot, readRefSlot } from "./ref-slot.js";
29
+
30
+ export interface RefReadingScope {
31
+ /** The definition a canonical kind names. */
32
+ resolve(kind: string): Record<string, any> | undefined;
33
+ /** Canonicalizes an alias-qualified kind, for a slot whose constraint has not
34
+ * been rewritten yet. */
35
+ resolveKind?(kind: string): string | undefined;
36
+ }
37
+
38
+ function isObject(value: unknown): value is Record<string, any> {
39
+ return !!value && typeof value === "object" && !Array.isArray(value);
40
+ }
41
+
42
+ /**
43
+ * The reading schema for one ref slot, or undefined when the slot's constraint
44
+ * resolves to nothing — in which case the slot is left exactly as it was, the
45
+ * degrade-to-today direction every unresolved annotation takes here.
46
+ *
47
+ * A slot constrained to SEVERAL kinds types `status` from the kinds that agree
48
+ * and drops the rest: a read valid against one permitted target must not be
49
+ * rejected because a sibling kind does not declare that field.
50
+ */
51
+ export function refSlotReadingSchema(
52
+ slot: Record<string, any>,
53
+ scope: RefReadingScope,
54
+ ): Record<string, any> | undefined {
55
+ const ref = readRefSlot(slot);
56
+ if (!ref || ref.kinds.length === 0) return undefined;
57
+
58
+ const statuses: Record<string, any>[] = [];
59
+ for (const kind of ref.kinds) {
60
+ const canonical = scope.resolveKind?.(kind) ?? kind;
61
+ const definition = scope.resolve(canonical) ?? scope.resolve(kind);
62
+ if (!definition) continue;
63
+ const status = effectiveStatusSchema(definition as any, (k) => scope.resolve(k) as any);
64
+ if (isObject(status) && isObject(status.properties)) statuses.push(status);
65
+ }
66
+ if (statuses.length === 0) return openReading(slot);
67
+
68
+ const shared = statuses.length === 1 ? statuses[0]!.properties : sharedProperties(statuses);
69
+ return {
70
+ ...describedBy(slot),
71
+ type: "object",
72
+ additionalProperties: true,
73
+ properties: {
74
+ status: { type: "object", additionalProperties: false, properties: shared },
75
+ },
76
+ };
77
+ }
78
+
79
+ /** Properties every candidate kind declares. A field only some of them report
80
+ * is not one a read can rely on, and rejecting it would be wrong for the kinds
81
+ * that do — so it degrades to the open half rather than to an error. */
82
+ function sharedProperties(statuses: readonly Record<string, any>[]): Record<string, any> {
83
+ const [first, ...rest] = statuses;
84
+ const out: Record<string, any> = {};
85
+ for (const [name, schema] of Object.entries(first!.properties as Record<string, any>)) {
86
+ if (rest.every((other) => name in (other.properties as Record<string, any>))) {
87
+ out[name] = schema;
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /** A reading whose `status` cannot be typed: open, so nothing new is rejected. */
94
+ function openReading(slot: Record<string, any>): Record<string, any> {
95
+ return { ...describedBy(slot), type: "object", additionalProperties: true };
96
+ }
97
+
98
+ /** The slot's own prose, kept so hover and completion still say what it is. */
99
+ function describedBy(slot: Record<string, any>): Record<string, any> {
100
+ const out: Record<string, any> = {};
101
+ if (typeof slot.title === "string") out.title = slot.title;
102
+ if (typeof slot.description === "string") out.description = slot.description;
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Rewrite every ref slot in a kind's own `schema:` to what reading it yields, so
108
+ * the `self` CEL variable answers about published state rather than about the
109
+ * annotation node.
110
+ *
111
+ * A SCHEMA walk, so it recurses through `properties` / `items` /
112
+ * `additionalProperties` and nothing else — there is no inline-declaration
113
+ * boundary to stop at here, because a `kind` key inside a schema is a property
114
+ * NAMED kind, not a nested resource. Untouched subtrees keep their identity, so
115
+ * a schema declaring no ref slot is returned as it was.
116
+ */
117
+ export function withRefSlotsAsReadings(
118
+ schema: unknown,
119
+ scope: RefReadingScope,
120
+ ): unknown {
121
+ if (Array.isArray(schema)) return schema.map((item) => withRefSlotsAsReadings(item, scope));
122
+ if (!isObject(schema)) return schema;
123
+
124
+ if (isRefSlot(schema)) return refSlotReadingSchema(schema, scope) ?? schema;
125
+
126
+ let changed = false;
127
+ const out: Record<string, any> = { ...schema };
128
+ for (const key of ["properties", "items", "additionalProperties"]) {
129
+ const node = schema[key];
130
+ if (node === undefined) continue;
131
+ const next =
132
+ key === "properties"
133
+ ? mapValues(node, (value) => withRefSlotsAsReadings(value, scope))
134
+ : withRefSlotsAsReadings(node, scope);
135
+ if (next !== node) {
136
+ out[key] = next;
137
+ changed = true;
138
+ }
139
+ }
140
+ return changed ? out : schema;
141
+ }
142
+
143
+ function mapValues(
144
+ node: unknown,
145
+ fn: (value: unknown) => unknown,
146
+ ): unknown {
147
+ if (!isObject(node)) return node;
148
+ let changed = false;
149
+ const out: Record<string, any> = {};
150
+ for (const [key, value] of Object.entries(node)) {
151
+ const next = fn(value);
152
+ if (next !== value) changed = true;
153
+ out[key] = next;
154
+ }
155
+ return changed ? out : node;
156
+ }