@telorun/analyzer 0.58.0 → 0.60.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 (89) hide show
  1. package/dist/analyzer.d.ts +9 -2
  2. package/dist/analyzer.d.ts.map +1 -1
  3. package/dist/analyzer.js +38 -10
  4. package/dist/builtins.d.ts.map +1 -1
  5. package/dist/builtins.js +16 -42
  6. package/dist/cel-environment.d.ts.map +1 -1
  7. package/dist/cel-environment.js +35 -0
  8. package/dist/flatten-for-analyzer.d.ts +4 -0
  9. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  10. package/dist/flatten-for-analyzer.js +8 -0
  11. package/dist/index.d.ts +6 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +8 -2
  14. package/dist/invocation-contract.d.ts +37 -0
  15. package/dist/invocation-contract.d.ts.map +1 -1
  16. package/dist/invocation-contract.js +81 -1
  17. package/dist/kernel-globals.d.ts +1 -1
  18. package/dist/kernel-globals.d.ts.map +1 -1
  19. package/dist/kernel-globals.js +15 -1
  20. package/dist/manifest-schemas.d.ts +407 -0
  21. package/dist/manifest-schemas.d.ts.map +1 -0
  22. package/dist/manifest-schemas.js +396 -0
  23. package/dist/module-file-claims.d.ts +6 -0
  24. package/dist/module-file-claims.d.ts.map +1 -1
  25. package/dist/module-file-claims.js +4 -0
  26. package/dist/module-metadata-scope.d.ts +44 -0
  27. package/dist/module-metadata-scope.d.ts.map +1 -0
  28. package/dist/module-metadata-scope.js +82 -0
  29. package/dist/parse-loaded-file.d.ts.map +1 -1
  30. package/dist/parse-loaded-file.js +16 -0
  31. package/dist/release/bump-level.d.ts +46 -0
  32. package/dist/release/bump-level.d.ts.map +1 -0
  33. package/dist/release/bump-level.js +80 -0
  34. package/dist/release/changelog.d.ts +36 -0
  35. package/dist/release/changelog.d.ts.map +1 -0
  36. package/dist/release/changelog.js +53 -0
  37. package/dist/release/fragment.d.ts +48 -0
  38. package/dist/release/fragment.d.ts.map +1 -0
  39. package/dist/release/fragment.js +84 -0
  40. package/dist/release/index.d.ts +26 -0
  41. package/dist/release/index.d.ts.map +1 -0
  42. package/dist/release/index.js +18 -0
  43. package/dist/release/ledger.d.ts +53 -0
  44. package/dist/release/ledger.d.ts.map +1 -0
  45. package/dist/release/ledger.js +109 -0
  46. package/dist/release/payload-digest.d.ts +63 -0
  47. package/dist/release/payload-digest.d.ts.map +1 -0
  48. package/dist/release/payload-digest.js +65 -0
  49. package/dist/release/release-plan.d.ts +128 -0
  50. package/dist/release/release-plan.d.ts.map +1 -0
  51. package/dist/release/release-plan.js +277 -0
  52. package/dist/release/version-stamp.d.ts +50 -0
  53. package/dist/release/version-stamp.d.ts.map +1 -0
  54. package/dist/release/version-stamp.js +119 -0
  55. package/dist/release/workspace-config.d.ts +41 -0
  56. package/dist/release/workspace-config.d.ts.map +1 -0
  57. package/dist/release/workspace-config.js +60 -0
  58. package/dist/schema-compat.d.ts +18 -0
  59. package/dist/schema-compat.d.ts.map +1 -1
  60. package/dist/schema-compat.js +71 -7
  61. package/dist/validate-cel-context.d.ts.map +1 -1
  62. package/dist/validate-cel-context.js +69 -13
  63. package/dist/validate-step-inputs.d.ts +1 -1
  64. package/dist/validate-step-inputs.d.ts.map +1 -1
  65. package/dist/validate-step-inputs.js +99 -8
  66. package/package.json +3 -3
  67. package/src/analyzer.ts +41 -10
  68. package/src/builtins.ts +16 -42
  69. package/src/cel-environment.ts +37 -0
  70. package/src/flatten-for-analyzer.ts +12 -0
  71. package/src/index.ts +18 -3
  72. package/src/invocation-contract.ts +92 -1
  73. package/src/kernel-globals.ts +23 -1
  74. package/src/manifest-schemas.ts +408 -0
  75. package/src/module-file-claims.ts +10 -0
  76. package/src/module-metadata-scope.ts +88 -0
  77. package/src/parse-loaded-file.ts +16 -0
  78. package/src/release/bump-level.ts +95 -0
  79. package/src/release/changelog.ts +62 -0
  80. package/src/release/fragment.ts +130 -0
  81. package/src/release/index.ts +66 -0
  82. package/src/release/ledger.ts +142 -0
  83. package/src/release/payload-digest.ts +83 -0
  84. package/src/release/release-plan.ts +392 -0
  85. package/src/release/version-stamp.ts +142 -0
  86. package/src/release/workspace-config.ts +81 -0
  87. package/src/schema-compat.ts +69 -5
  88. package/src/validate-cel-context.ts +82 -13
  89. package/src/validate-step-inputs.ts +109 -9
@@ -3,7 +3,6 @@ import addFormats from "ajv-formats";
3
3
  import {
4
4
  isRefSentinel,
5
5
  isTaggedSentinel,
6
- ManifestRootSchema,
7
6
  producedTypeOf,
8
7
  } from "@telorun/templating";
9
8
  import {
@@ -14,6 +13,7 @@ import {
14
13
  valueTypeOf,
15
14
  valueTypePlaceholder,
16
15
  } from "@telorun/sdk";
16
+ import { ManifestRootSchema } from "./manifest-schemas.js";
17
17
  import { registerTeloKeywords } from "./value-type-keyword.js";
18
18
 
19
19
  const Ajv = (AjvModule as any).default ?? AjvModule;
@@ -48,6 +48,16 @@ export interface CompatibilityResult {
48
48
  issues: string[];
49
49
  }
50
50
 
51
+ /** The alternatives a union node declares, or undefined when it is not one.
52
+ * `anyOf` and `oneOf` are one question here — which branches could accept this
53
+ * value — and their difference (exactly-one vs at-least-one) is a validation
54
+ * rule, not a compatibility one. */
55
+ function unionBranches(schema: Record<string, any>): Record<string, any>[] | undefined {
56
+ const branches = schema.anyOf ?? schema.oneOf;
57
+ if (!Array.isArray(branches) || branches.length === 0) return undefined;
58
+ return branches.filter((b) => b && typeof b === "object") as Record<string, any>[];
59
+ }
60
+
51
61
  /**
52
62
  * Conservative structural JSON Schema compatibility check — is a value shaped
53
63
  * like `source` acceptable where `target` is declared?
@@ -115,6 +125,38 @@ function compare(
115
125
  const source = deref(rawSource, resolveRef);
116
126
  const target = deref(rawTarget, resolveRef);
117
127
 
128
+ // A union is ALTERNATIVES, so it is compared by distributing over branches on
129
+ // both sides: a definite conflict is one where no source-branch/target-branch
130
+ // pair agrees. Returning silently the moment either side was a union — which
131
+ // is what this did — switched the whole comparison off for any slot that
132
+ // accepts more than one shape, and those are exactly the slots where a value
133
+ // type carries the only information distinguishing the branches.
134
+ //
135
+ // `allOf` is a conjunction rather than a choice, so it keeps the old posture:
136
+ // it says too little to judge and stays compatible.
137
+ if (source.allOf || target.allOf) return;
138
+ const sourceBranches = unionBranches(source);
139
+ const targetBranches = unionBranches(target);
140
+ if (sourceBranches || targetBranches) {
141
+ const lefts = sourceBranches ?? [source];
142
+ const rights = targetBranches ?? [target];
143
+ const reasons: string[] = [];
144
+ for (const left of lefts) {
145
+ for (const right of rights) {
146
+ const probe: string[] = [];
147
+ // A fresh `seen` per probe: a pair rejected on one branch must not mark
148
+ // a reference pair visited for the next, which would silently pass it.
149
+ compare(left, right, path, probe, resolveRef, new Set(seen));
150
+ if (probe.length === 0) return;
151
+ reasons.push(...probe);
152
+ }
153
+ }
154
+ issues.push(
155
+ `${path || "/"}: no alternative matches — ${[...new Set(reasons)].join("; ")}`,
156
+ );
157
+ return;
158
+ }
159
+
118
160
  // Value types first: an `instance` representation has no JSON `type` to
119
161
  // compare, so its identity IS the comparison — and its arguments are where the
120
162
  // real information lives.
@@ -180,8 +222,6 @@ function compare(
180
222
  );
181
223
  return;
182
224
  }
183
- if (source.anyOf || source.oneOf || source.allOf) return;
184
- if (target.anyOf || target.oneOf || target.allOf) return;
185
225
 
186
226
  // An array's element, which the old comparison never looked at — so every
187
227
  // nested shape passed regardless of what it contained.
@@ -425,7 +465,14 @@ export function celTypeSatisfiesJsonSchema(celType: string, schema: Record<strin
425
465
  return true;
426
466
  }
427
467
  if (!schema.type && !schema.anyOf && !schema.oneOf && !schema.allOf) return true;
428
- if (schema.anyOf || schema.oneOf || schema.allOf) return true;
468
+ // `allOf` is a conjunction and says too little to judge from a single CEL
469
+ // type. A union is a CHOICE, so it is satisfied by satisfying any branch —
470
+ // distributed for the same reason `compare` does it: accepting every union
471
+ // outright turns the check off for exactly the slots that admit more than one
472
+ // shape, and those are the ones where the branches carry the information.
473
+ if (schema.allOf) return true;
474
+ const branches = unionBranches(schema);
475
+ if (branches) return branches.some((branch) => celTypeSatisfiesJsonSchema(celType, branch));
429
476
  const schemaTypes = Array.isArray(schema.type) ? schema.type : [schema.type];
430
477
  const accepted: Record<string, string[]> = {
431
478
  int: ["integer", "number"],
@@ -528,6 +575,23 @@ export function celPlaceholderForSchema(rawSchema: Record<string, any>): unknown
528
575
  // member chosen is irrelevant — only its acceptability to AJV matters, since
529
576
  // the real value is checked at runtime once the expression resolves.
530
577
  if (Array.isArray(schema.enum) && schema.enum.length > 0) return schema.enum[0];
578
+ // A UNION with no `type` of its own. Without this, a whole-field CEL leaf at
579
+ // such a slot gets `null`, which every branch then rejects — so a field
580
+ // declared `anyOf: [array, boolean]` could not be written as an expression at
581
+ // all, while one whose union happens to contain a `live` branch escaped by
582
+ // accident (nothing validates a live value, so `null` passed). The first
583
+ // branch that yields a placeholder wins: the same conservative posture
584
+ // `selectUnionBranch` takes, and enough for AJV, whose question is only
585
+ // whether SOME branch accepts the stand-in.
586
+ if (schema.type === undefined) {
587
+ const branches = unionBranches(schema);
588
+ if (branches) {
589
+ for (const branch of branches) {
590
+ const candidate = celPlaceholderForSchema(branch);
591
+ if (candidate !== null) return candidate;
592
+ }
593
+ }
594
+ }
531
595
  switch (schema.type) {
532
596
  case "integer":
533
597
  case "number":
@@ -603,7 +667,7 @@ export function resolveRef(schema: Record<string, any>, root: Record<string, any
603
667
  * — an ambiguous union is one the analyzer should not resolve on the author's
604
668
  * behalf.
605
669
  */
606
- function selectUnionBranch(
670
+ export function selectUnionBranch(
607
671
  schema: Record<string, any>,
608
672
  data: unknown,
609
673
  root: Record<string, any>,
@@ -1,5 +1,10 @@
1
1
  export { extractAccessChains, validateChainAgainstSchema } from "@telorun/templating";
2
- import { mergeTypeSchemas, parseCanonicalTypeSchemaId } from "@telorun/sdk";
2
+ import {
3
+ elementSchemaOf,
4
+ isLiveSlot,
5
+ mergeTypeSchemas,
6
+ parseCanonicalTypeSchemaId,
7
+ } from "@telorun/sdk";
3
8
  import { KERNEL_BUILTINS } from "./builtins.js";
4
9
 
5
10
  export interface ContextResolveOpts {
@@ -206,23 +211,73 @@ function schemaAtChain(
206
211
  return cur && typeof cur === "object" ? cur : undefined;
207
212
  }
208
213
 
209
- /** The element schema of a sibling collection expression, when statically known.
210
- * Resolves `inputs.*` chains against the resource's `inputs:` contract and
211
- * returns the array's `items`. Returns undefined for non-chain or untyped
212
- * collections (caller substitutes `dyn`). */
213
- function resolveCollectionElementSchema(
214
+ /**
215
+ * The schema of a sibling collection expression, when statically known.
216
+ *
217
+ * Resolves `inputs.*` chains against the resource's DECLARED contract, falling
218
+ * back to the legacy `inputs:` property map some kinds still carry. Reading the
219
+ * contract is what makes this work at all for a kind that declares `inputType:`
220
+ * — resolving only the property map left `item` untyped in every such kind,
221
+ * silently, which reads as "element typing is permissive here" rather than as a
222
+ * gap. Returns undefined for a non-chain or untyped collection, and the caller
223
+ * substitutes `dyn` rather than inventing an element type.
224
+ */
225
+ function resolveCollectionSchema(
214
226
  manifestRoot: Record<string, any>,
215
227
  field: string,
228
+ allManifests: Record<string, any>[] | undefined,
216
229
  ): Record<string, any> | undefined {
217
230
  const chain = purePathChain(manifestRoot?.[field]);
218
231
  if (!chain || chain[0] !== "inputs") return undefined;
219
- const contract = manifestRoot.inputs;
220
- if (!contract || typeof contract !== "object") return undefined;
221
- const terminal = schemaAtChain(chain.slice(1), { type: "object", properties: contract });
222
- if (terminal && terminal.type === "array" && terminal.items && typeof terminal.items === "object") {
223
- return terminal.items as Record<string, any>;
232
+ const declared = resolveTypeFieldToSchema(manifestRoot.inputType, allManifests ?? []);
233
+ const root =
234
+ declared && typeof declared === "object"
235
+ ? declared
236
+ : manifestRoot.inputs && typeof manifestRoot.inputs === "object"
237
+ ? { type: "object", properties: manifestRoot.inputs }
238
+ : undefined;
239
+ if (!root) return undefined;
240
+ return schemaAtChain(chain.slice(1), root);
241
+ }
242
+
243
+ /**
244
+ * What ITERATING a collection schema yields.
245
+ *
246
+ * An array answers with `items`; anything else answers through the value-type
247
+ * vocabulary, which is where "what is the element of this" is declared. No type
248
+ * is named here on purpose: a future iterable value type is covered by declaring
249
+ * `element` on one of its parameters, with nothing to change in the analyzer.
250
+ */
251
+ function elementOfCollection(
252
+ collection: Record<string, any> | undefined,
253
+ ): Record<string, any> | undefined {
254
+ if (!collection || typeof collection !== "object") return undefined;
255
+ if (collection.type === "array") {
256
+ return collection.items && typeof collection.items === "object"
257
+ ? (collection.items as Record<string, any>)
258
+ : undefined;
224
259
  }
225
- return undefined;
260
+ const element = elementSchemaOf(collection);
261
+ return element && typeof element === "object" ? (element as Record<string, any>) : undefined;
262
+ }
263
+
264
+ /**
265
+ * True when a context binding naming this collection must NOT be bound.
266
+ *
267
+ * A binding that re-exposes the collection can only hand over the value the
268
+ * consumer is already draining, and passing that on is an ordinary
269
+ * pass-through no member-access rule catches — so the drain is silent. `live` is
270
+ * exactly the property that makes a value unsafe to re-expose, and it is already
271
+ * in the vocabulary, so no consumer names a type to decide this.
272
+ */
273
+ function collectionBindingWithheld(
274
+ schema: Record<string, any>,
275
+ manifestRoot: Record<string, any>,
276
+ allManifests: Record<string, any>[] | undefined,
277
+ ): boolean {
278
+ const from = schema?.["x-telo-context-collection-from"] as string | undefined;
279
+ if (!from) return false;
280
+ return isLiveSlot(resolveCollectionSchema(manifestRoot, from, allManifests));
226
281
  }
227
282
 
228
283
  /**
@@ -342,10 +397,20 @@ export function resolveContextAnnotations(
342
397
  // untyped sources fall back to `dyn` so a wrong element type is never invented.
343
398
  const elementFrom = schema["x-telo-context-element-from"] as string | undefined;
344
399
  if (elementFrom) {
345
- const items = resolveCollectionElementSchema(manifestRoot, elementFrom);
400
+ const items = elementOfCollection(resolveCollectionSchema(manifestRoot, elementFrom, allManifests));
346
401
  return items ?? {};
347
402
  }
348
403
 
404
+ // The collection itself, as opposed to its element. Typed from whatever the
405
+ // sibling resolves to, so an array binding keeps the precision it has today
406
+ // instead of degrading to `dyn` now that the slot admits more than one shape.
407
+ // A `live` collection never reaches here — the property is dropped before
408
+ // recursion, so the name is simply not in scope.
409
+ const collectionFrom = schema["x-telo-context-collection-from"] as string | undefined;
410
+ if (collectionFrom) {
411
+ return resolveCollectionSchema(manifestRoot, collectionFrom, allManifests) ?? {};
412
+ }
413
+
349
414
  const fromRoot = schema["x-telo-context-from-root"] as string | undefined;
350
415
  const fromRefKindRaw = schema["x-telo-context-from-ref-kind"] as
351
416
  | string
@@ -445,6 +510,10 @@ export function resolveContextAnnotations(
445
510
  if (schema.properties) {
446
511
  const props: Record<string, any> = {};
447
512
  for (const [k, v] of Object.entries(schema.properties)) {
513
+ // Withholding happens HERE rather than inside the child resolver, because
514
+ // this is the only level that owns the property map — a child can return a
515
+ // schema but cannot remove itself from one.
516
+ if (collectionBindingWithheld(v as Record<string, any>, manifestRoot, allManifests)) continue;
448
517
  props[k] = resolveContextAnnotations(v as Record<string, any>, manifestItem, normalizedOpts);
449
518
  }
450
519
  return { ...schema, properties: props };
@@ -9,7 +9,8 @@ import {
9
9
  validateAgainstSchema,
10
10
  } from "./schema-compat.js";
11
11
  import { plainChainOf } from "@telorun/templating";
12
- import { valueTypeOf } from "@telorun/sdk";
12
+ import { isLiveSlot, valueTypeOf } from "@telorun/sdk";
13
+ import { manifestFragmentOf } from "./manifest-schemas.js";
13
14
  import {
14
15
  analyzerContractScope,
15
16
  containerOf,
@@ -25,7 +26,7 @@ export interface StepInputIssue {
25
26
  message: string;
26
27
  /** Set when the issue is a type-argument disagreement rather than a contract
27
28
  * shape violation — the two read differently and deserve their own code. */
28
- code?: "CEL_TYPE_ARGUMENT_MISMATCH";
29
+ code?: "CEL_TYPE_ARGUMENT_MISMATCH" | "LIVE_VALUE_RETRIED";
29
30
  }
30
31
 
31
32
 
@@ -118,15 +119,30 @@ export function collectStepInputIssues(
118
119
  // that silence is exactly where a stream of the wrong element used to
119
120
  // flow. The comparison is covariant and gradual: an omitted argument is
120
121
  // *any* in both directions, so only a definite conflict is reported.
121
- if (stepContext) {
122
+ // The roots a plain chain may name here, each paired with the schema it is
123
+ // navigated against. `steps.` is the step map (analyzer state, supplied by
124
+ // the caller). `inputs.` is the ENCLOSING kind's own declared inputType,
125
+ // which is how a value produced OUTSIDE this resource reaches a step at
126
+ // all: an HTTP route maps `request.body` into its handler's inputs, and the
127
+ // handler forwards `inputs.body` onward — the shape a live value most often
128
+ // arrives in, and the one covering only `steps.` missed entirely. A root
129
+ // this cannot resolve contributes nothing rather than guessing at a schema.
130
+ const roots: Array<[string, Record<string, any>]> = [];
131
+ if (stepContext) roots.push(["steps.", stepContext]);
132
+ const ownContract = resolveContract(
133
+ "inputType",
134
+ manifest,
135
+ contractScope.resolveIn(manifest.kind as string, readingModule),
136
+ contractScope,
137
+ );
138
+ if (ownContract) roots.push(["inputs.", ownContract.schema]);
139
+
140
+ if (roots.length > 0) {
122
141
  for (const [inputName, inputValue] of Object.entries(values)) {
123
142
  const chain = plainChainOf(inputValue);
124
- // The step context is rooted at the STEP MAP, so a `steps.` prefix is
125
- // the namespace name and not a property of it. Only that namespace is
126
- // navigated: `inputs.` and a named binding resolve elsewhere, and
127
- // guessing at a root this does not hold would compare the wrong schema.
128
- if (!chain?.startsWith("steps.")) continue;
129
- const produced = navigateSchemaToExprPath(stepContext, chain.slice("steps.".length));
143
+ const root = chain ? roots.find(([prefix]) => chain.startsWith(prefix)) : undefined;
144
+ if (!chain || !root) continue;
145
+ const produced = navigateSchemaToExprPath(root[1], chain.slice(root[0].length));
130
146
  const slotSchema = (contract.schema.properties as Record<string, any> | undefined)?.[
131
147
  inputName
132
148
  ];
@@ -138,6 +154,28 @@ export function collectStepInputIssues(
138
154
  // broad new Error-severity check hidden behind an argument-specific
139
155
  // name. Both sides must declare a value type for the question to be
140
156
  // about arguments at all.
157
+ // A LIVE value is consumed by reading, so it exists exactly once —
158
+ // that is what `live` says in the vocabulary, and re-attempting a
159
+ // dispatch that already read it re-sends nothing. Reported here rather
160
+ // than through a slot-specific annotation because both facts are
161
+ // already declared: the value's liveness by its value type, and the
162
+ // re-attempt by the retry policy. No kind is named.
163
+ if (isLiveSlot(produced)) {
164
+ const retry = declaredRetry(step, stepItemSchema, invokedManifest, invokedDef);
165
+ if (retry !== undefined) {
166
+ out.push({
167
+ path: `${stepPath}.${inputsField}.${inputName}`,
168
+ targetLabel: invokedName ?? invokedKind ?? "the invoked resource",
169
+ message:
170
+ `'${inputName}' is a live value, which is consumed by reading and so exists ` +
171
+ `once — but ${retry} re-attempts the dispatch, and a re-attempt would pass ` +
172
+ `nothing. Collect it to a value first, or chunk the work so each attempt ` +
173
+ `carries its own replayable piece.`,
174
+ code: "LIVE_VALUE_RETRIED",
175
+ });
176
+ continue;
177
+ }
178
+ }
141
179
  if (!valueTypeOf(produced) || !valueTypeOf(slotSchema)) continue;
142
180
  const { compatible, issues } = checkSchemaCompatibility(produced, slotSchema, (ref) =>
143
181
  defs.schemaForId(ref),
@@ -170,3 +208,65 @@ export function collectStepInputIssues(
170
208
  return out;
171
209
  }
172
210
 
211
+ /**
212
+ * Where a re-attempt is declared for this dispatch, described for a diagnostic,
213
+ * or undefined when none is.
214
+ *
215
+ * A field declares one when its schema was expanded from a shared retry fragment
216
+ * — the shape the author pointed at, rather than a marker they had to remember to
217
+ * write beside it. Which fragment also says WHERE the budget is, so the two
218
+ * spellings a kind may carry (a policy object, or the deprecated bare count) need
219
+ * no guessing between them and no rule about which one wins.
220
+ *
221
+ * Two sites are consulted because there are two real ones: the STEP's own policy
222
+ * — `retry` on the kernel-owned dispatch site — and the TARGET's, a field on an
223
+ * arbitrary kind, because `Http.Request` re-attempts inside its own `invoke()`
224
+ * where only it can tell a 429 from a 500. A live value is equally doomed by
225
+ * either. EVERY retry-bearing field at a site is checked, not the first, since
226
+ * `Http.Request` carries both spellings and property order must not decide which
227
+ * is seen.
228
+ *
229
+ * Only a STATICALLY KNOWN non-zero budget counts. An `attempts` written as CEL
230
+ * says nothing here, and guessing would report a conflict against a manifest that
231
+ * may never retry — the same posture the `use` case-map selector takes.
232
+ */
233
+ function declaredRetry(
234
+ step: Record<string, any>,
235
+ stepItemSchema: Record<string, any> | undefined,
236
+ invokedManifest: Record<string, any> | undefined,
237
+ invokedDef: Record<string, any> | undefined,
238
+ ): string | undefined {
239
+ for (const [field, budget] of retryFields(stepItemSchema)) {
240
+ if (budget(step?.[field]) > 0) return `the step's \`${field}\``;
241
+ }
242
+ for (const [field, budget] of retryFields(invokedDef?.schema as Record<string, any>)) {
243
+ if (budget(invokedManifest?.[field]) > 0) return `the target's \`${field}\``;
244
+ }
245
+ return undefined;
246
+ }
247
+
248
+ /** How each shared retry fragment carries its budget. Keyed on fragment name —
249
+ * the analyzer's own built-ins, never a module's kind — so a kind that adopts a
250
+ * shape is covered without naming it here. */
251
+ const RETRY_BUDGET: Record<string, (value: unknown) => number> = {
252
+ RetryPolicy: (value) => {
253
+ if (!value || typeof value !== "object") return 0;
254
+ const attempts = (value as Record<string, unknown>).attempts;
255
+ return typeof attempts === "number" ? attempts : 0;
256
+ },
257
+ RetryAttempts: (value) => (typeof value === "number" ? value : 0),
258
+ };
259
+
260
+ /** Every property of `schema` whose shape came from a retry fragment, paired with
261
+ * the reader for that fragment's budget. */
262
+ function retryFields(
263
+ schema: Record<string, any> | undefined,
264
+ ): Array<[string, (value: unknown) => number]> {
265
+ if (!schema) return [];
266
+ const out: Array<[string, (value: unknown) => number]> = [];
267
+ for (const [key, sub] of gatherPropertySchemas(schema)) {
268
+ const budget = RETRY_BUDGET[manifestFragmentOf(sub) ?? ""];
269
+ if (budget) out.push([key, budget]);
270
+ }
271
+ return out;
272
+ }