@telorun/analyzer 0.59.0 → 0.61.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 (95) 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/artifact-layer-index.d.ts +13 -5
  5. package/dist/artifact-layer-index.d.ts.map +1 -1
  6. package/dist/artifact-layer-index.js +37 -13
  7. package/dist/artifact-selector.d.ts +10 -3
  8. package/dist/artifact-selector.d.ts.map +1 -1
  9. package/dist/artifact-selector.js +10 -1
  10. package/dist/builtins.d.ts.map +1 -1
  11. package/dist/builtins.js +44 -43
  12. package/dist/cel-environment.d.ts.map +1 -1
  13. package/dist/cel-environment.js +35 -0
  14. package/dist/flatten-for-analyzer.d.ts +4 -0
  15. package/dist/flatten-for-analyzer.d.ts.map +1 -1
  16. package/dist/flatten-for-analyzer.js +8 -0
  17. package/dist/index.d.ts +7 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +9 -2
  20. package/dist/kernel-globals.d.ts +1 -1
  21. package/dist/kernel-globals.d.ts.map +1 -1
  22. package/dist/kernel-globals.js +15 -1
  23. package/dist/manifest-schemas.d.ts +407 -0
  24. package/dist/manifest-schemas.d.ts.map +1 -0
  25. package/dist/manifest-schemas.js +396 -0
  26. package/dist/module-file-claims.d.ts +13 -0
  27. package/dist/module-file-claims.d.ts.map +1 -1
  28. package/dist/module-file-claims.js +26 -2
  29. package/dist/module-library.d.ts +97 -0
  30. package/dist/module-library.d.ts.map +1 -0
  31. package/dist/module-library.js +162 -0
  32. package/dist/module-metadata-scope.d.ts +44 -0
  33. package/dist/module-metadata-scope.d.ts.map +1 -0
  34. package/dist/module-metadata-scope.js +82 -0
  35. package/dist/parse-loaded-file.d.ts.map +1 -1
  36. package/dist/parse-loaded-file.js +16 -0
  37. package/dist/release/bump-level.d.ts +46 -0
  38. package/dist/release/bump-level.d.ts.map +1 -0
  39. package/dist/release/bump-level.js +80 -0
  40. package/dist/release/changelog.d.ts +36 -0
  41. package/dist/release/changelog.d.ts.map +1 -0
  42. package/dist/release/changelog.js +53 -0
  43. package/dist/release/fragment.d.ts +48 -0
  44. package/dist/release/fragment.d.ts.map +1 -0
  45. package/dist/release/fragment.js +84 -0
  46. package/dist/release/index.d.ts +26 -0
  47. package/dist/release/index.d.ts.map +1 -0
  48. package/dist/release/index.js +18 -0
  49. package/dist/release/ledger.d.ts +53 -0
  50. package/dist/release/ledger.d.ts.map +1 -0
  51. package/dist/release/ledger.js +109 -0
  52. package/dist/release/payload-digest.d.ts +63 -0
  53. package/dist/release/payload-digest.d.ts.map +1 -0
  54. package/dist/release/payload-digest.js +65 -0
  55. package/dist/release/release-plan.d.ts +128 -0
  56. package/dist/release/release-plan.d.ts.map +1 -0
  57. package/dist/release/release-plan.js +277 -0
  58. package/dist/release/version-stamp.d.ts +50 -0
  59. package/dist/release/version-stamp.d.ts.map +1 -0
  60. package/dist/release/version-stamp.js +119 -0
  61. package/dist/release/workspace-config.d.ts +41 -0
  62. package/dist/release/workspace-config.d.ts.map +1 -0
  63. package/dist/release/workspace-config.js +60 -0
  64. package/dist/schema-compat.js +2 -1
  65. package/dist/validate-module-artifact.d.ts.map +1 -1
  66. package/dist/validate-module-artifact.js +53 -1
  67. package/dist/validate-step-inputs.d.ts +1 -1
  68. package/dist/validate-step-inputs.d.ts.map +1 -1
  69. package/dist/validate-step-inputs.js +99 -8
  70. package/package.json +3 -3
  71. package/src/analyzer.ts +41 -10
  72. package/src/artifact-layer-index.ts +47 -14
  73. package/src/artifact-selector.ts +15 -4
  74. package/src/builtins.ts +45 -43
  75. package/src/cel-environment.ts +37 -0
  76. package/src/flatten-for-analyzer.ts +12 -0
  77. package/src/index.ts +19 -2
  78. package/src/kernel-globals.ts +23 -1
  79. package/src/manifest-schemas.ts +408 -0
  80. package/src/module-file-claims.ts +42 -2
  81. package/src/module-library.ts +208 -0
  82. package/src/module-metadata-scope.ts +88 -0
  83. package/src/parse-loaded-file.ts +16 -0
  84. package/src/release/bump-level.ts +95 -0
  85. package/src/release/changelog.ts +62 -0
  86. package/src/release/fragment.ts +130 -0
  87. package/src/release/index.ts +66 -0
  88. package/src/release/ledger.ts +142 -0
  89. package/src/release/payload-digest.ts +83 -0
  90. package/src/release/release-plan.ts +392 -0
  91. package/src/release/version-stamp.ts +142 -0
  92. package/src/release/workspace-config.ts +81 -0
  93. package/src/schema-compat.ts +1 -1
  94. package/src/validate-module-artifact.ts +56 -0
  95. package/src/validate-step-inputs.ts +109 -9
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Writing a module's one version into every manifest it owns.
3
+ *
4
+ * A module has a single version across `telo.yaml`, `nodejs/package.json` and
5
+ * `rust/Cargo.toml`. Three formats, one rule: find the scalar, splice over its
6
+ * span, touch nothing else. That is `yaml-source-edit.ts`'s primitive — the same
7
+ * one the quick fix, `telo migrate` and `telo upgrade`'s pin rewrite use — so a
8
+ * bump lands as a one-line diff instead of a re-serialized file that re-folds
9
+ * every block scalar in a 900-line manifest.
10
+ *
11
+ * Each stamp returns `undefined` when the file carries no version to write,
12
+ * which is not an error: a module may own only a `telo.yaml`, and 42 of the
13
+ * standard library's packages have no Rust crate. A file that *has* a version in
14
+ * a shape this cannot address is a hard error instead, because silently skipping
15
+ * it would publish an artifact whose manifests disagree about what it is.
16
+ */
17
+
18
+ import { defaultCustomTags } from "@telorun/templating";
19
+ import { isScalar, parseAllDocuments, parseDocument, type Scalar } from "yaml";
20
+ import { applyTextEdits, renderFixReplacement } from "../yaml-source-edit.js";
21
+
22
+ export class VersionStampError extends Error {}
23
+
24
+ /** Replace the scalar at `[start, end)` with `version`, re-quoted in the
25
+ * author's own style. */
26
+ function spliceScalar(text: string, node: Scalar, version: string, where: string): string {
27
+ const range = node.range;
28
+ if (!range) {
29
+ throw new VersionStampError(`${where}: the version scalar carries no source range.`);
30
+ }
31
+ const [start, end] = range;
32
+ const source = text.slice(start, end);
33
+ const replacement = renderFixReplacement(source, version);
34
+ if (replacement === undefined) {
35
+ throw new VersionStampError(
36
+ `${where}: the version is written as '${source}', which cannot be rewritten in place. ` +
37
+ `Write it as a plain or quoted scalar on one line.`,
38
+ );
39
+ }
40
+ return applyTextEdits(text, [{ start, end, newText: replacement }]);
41
+ }
42
+
43
+ /**
44
+ * `metadata.version` on the **module doc** — the first document, the one whose
45
+ * kind is `Telo.Application` or `Telo.Library`. Deliberately not "any
46
+ * `metadata.version` in the file": a `Telo.Definition` further down may carry
47
+ * one, and the regex-replacement changie was configured with matched by line
48
+ * shape rather than by position, which is why it needed a hand-maintained count
49
+ * of how many lines it was allowed to hit.
50
+ */
51
+ export function stampManifestVersion(
52
+ text: string,
53
+ version: string,
54
+ where: string,
55
+ ): string | undefined {
56
+ const docs = parseAllDocuments(text, { customTags: defaultCustomTags() });
57
+ const doc = docs[0];
58
+ if (!doc) return undefined;
59
+ const metadata = doc.get("metadata", true);
60
+ if (!metadata || typeof (metadata as { get?: unknown }).get !== "function") return undefined;
61
+ const node = (metadata as { get(key: string, keepScalar: boolean): unknown }).get(
62
+ "version",
63
+ true,
64
+ );
65
+ if (!isScalar(node)) return undefined;
66
+ return spliceScalar(text, node, version, where);
67
+ }
68
+
69
+ /** Read the module doc's `metadata.version` without rewriting it. */
70
+ export function readManifestVersion(text: string): string | undefined {
71
+ const docs = parseAllDocuments(text, { customTags: defaultCustomTags() });
72
+ const first = docs[0]?.toJSON() as { kind?: unknown; metadata?: { version?: unknown } } | undefined;
73
+ if (first?.kind !== "Telo.Application" && first?.kind !== "Telo.Library") return undefined;
74
+ const version = first.metadata?.version;
75
+ return typeof version === "string" ? version : undefined;
76
+ }
77
+
78
+ /**
79
+ * The top-level `"version"` of a `package.json`.
80
+ *
81
+ * Parsed with the YAML reader rather than `JSON.parse`, because JSON is a YAML
82
+ * subset and this one needs the node's *source range* — `JSON.parse` discards it,
83
+ * and re-serializing with `JSON.stringify` would reformat a file whose
84
+ * indentation, key order and trailing newline are all conventions someone chose.
85
+ */
86
+ export function stampPackageVersion(
87
+ text: string,
88
+ version: string,
89
+ where: string,
90
+ ): string | undefined {
91
+ const doc = parseDocument(text);
92
+ const node = doc.get("version", true);
93
+ if (!isScalar(node)) return undefined;
94
+ return spliceScalar(text, node, version, where);
95
+ }
96
+
97
+ /**
98
+ * `version` in a `Cargo.toml`'s `[package]` table.
99
+ *
100
+ * Scanned rather than parsed: TOML is not YAML, adding a TOML parser to the
101
+ * browser-safe analyzer for one scalar is not a trade worth making, and the
102
+ * shape being addressed is the canonical one cargo itself writes. The scan is
103
+ * bounded to the `[package]` table so a `version` under `[dependencies.x]`
104
+ * cannot be hit, and a `[package]` whose version is not a simple quoted scalar
105
+ * is refused rather than guessed at.
106
+ */
107
+ export function stampCrateVersion(
108
+ text: string,
109
+ version: string,
110
+ where: string,
111
+ ): string | undefined {
112
+ const table = /^[ \t]*\[package\][ \t]*$/m.exec(text);
113
+ if (!table) return undefined;
114
+ const bodyStart = table.index + table[0].length;
115
+ const next = /^[ \t]*\[/m.exec(text.slice(bodyStart));
116
+ const bodyEnd = next ? bodyStart + next.index : text.length;
117
+
118
+ const entry = /^([ \t]*version[ \t]*=[ \t]*)(".*?"|'.*?')[ \t]*$/m.exec(
119
+ text.slice(bodyStart, bodyEnd),
120
+ );
121
+ if (!entry) {
122
+ // A `[package]` with `version.workspace = true` inherits from the workspace
123
+ // and genuinely has nothing here to stamp; anything else is a shape this
124
+ // cannot address, and writing nothing would leave the crate behind.
125
+ if (/^[ \t]*version[ \t]*\.[ \t]*workspace[ \t]*=/m.test(text.slice(bodyStart, bodyEnd))) {
126
+ return undefined;
127
+ }
128
+ if (/^[ \t]*version[ \t]*=/m.test(text.slice(bodyStart, bodyEnd))) {
129
+ throw new VersionStampError(
130
+ `${where}: [package].version is not a quoted scalar on one line, so it cannot be ` +
131
+ `rewritten in place.`,
132
+ );
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ const start = bodyStart + entry.index + entry[1].length;
138
+ const quote = entry[2][0];
139
+ return applyTextEdits(text, [
140
+ { start, end: start + entry[2].length, newText: `${quote}${version}${quote}` },
141
+ ]);
142
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `telo-workspace.yaml` — the release anchor.
3
+ *
4
+ * Every path the release system names — a module key, a ledger entry, a
5
+ * fragment's `modules:` — is relative to this file's directory. That is the
6
+ * whole of its job: **its location is the anchor**, and its one field names the
7
+ * subtrees that may hold modules, which is not derivable (a whole-tree scan
8
+ * would read every example and every cached `.telo/manifests` copy as a released
9
+ * module).
10
+ *
11
+ * The file is **optional and read only by `telo release`**. Nothing else — not
12
+ * `run`, `check`, `publish`, `install`, `upgrade`, `migrate`, `module`, and not
13
+ * the kernel — looks for it, so a single-manifest repo, a bare `examples/`
14
+ * directory and a third-party module checkout keep working with nothing added.
15
+ * Any field added later must be true of the whole tree, not derivable from it,
16
+ * and harmless by its absence.
17
+ *
18
+ * Parsing lives here, in the browser-safe half, because the editor answers
19
+ * "what does changing this library bump?" from the same model. Finding the file
20
+ * on disk is the CLI's half — this side takes text.
21
+ */
22
+
23
+ import { parseDocument } from "yaml";
24
+
25
+ export const WORKSPACE_FILENAME = "telo-workspace.yaml";
26
+
27
+ export interface WorkspaceConfig {
28
+ /**
29
+ * Gitignore-style patterns, workspace-relative, naming the subtrees that may
30
+ * hold modules (`modules/*`, `apps/*`). A pattern names a place to look, never
31
+ * a module: what makes a directory a module is its `telo.yaml`.
32
+ */
33
+ readonly modules: readonly string[];
34
+ }
35
+
36
+ export class WorkspaceConfigError extends Error {}
37
+
38
+ /**
39
+ * Parse the marker file's text.
40
+ *
41
+ * Strict about its one field, because there is nothing here to be lenient with:
42
+ * an empty or absent `modules:` names no subtree, so discovery would find no
43
+ * module and every gate would silently pass over a whole repo.
44
+ */
45
+ export function parseWorkspaceConfig(text: string, where: string): WorkspaceConfig {
46
+ let value: unknown;
47
+ try {
48
+ value = parseDocument(text).toJSON();
49
+ } catch (err) {
50
+ throw new WorkspaceConfigError(
51
+ `${where} is not valid YAML: ${err instanceof Error ? err.message : String(err)}`,
52
+ );
53
+ }
54
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
55
+ throw new WorkspaceConfigError(`${where} must be a YAML mapping.`);
56
+ }
57
+ const record = value as Record<string, unknown>;
58
+
59
+ for (const key of Object.keys(record)) {
60
+ if (key !== "modules") {
61
+ throw new WorkspaceConfigError(
62
+ `${where}: unknown field '${key}'. The workspace marker carries only 'modules:' — ` +
63
+ `the subtrees that may hold modules.`,
64
+ );
65
+ }
66
+ }
67
+
68
+ const modules = record.modules;
69
+ if (!Array.isArray(modules) || modules.some((entry) => typeof entry !== "string")) {
70
+ throw new WorkspaceConfigError(
71
+ `${where}: 'modules' must be a list of path patterns, e.g. [modules/*, apps/*].`,
72
+ );
73
+ }
74
+ if (modules.length === 0) {
75
+ throw new WorkspaceConfigError(
76
+ `${where}: 'modules' is empty, so no directory can ever be discovered as a module. ` +
77
+ `List the subtrees that hold them, e.g. [modules/*, apps/*].`,
78
+ );
79
+ }
80
+ return { modules: modules as string[] };
81
+ }
@@ -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;
@@ -5,7 +5,9 @@ import {
5
5
  ArtifactSelectorError,
6
6
  PLATFORM_AXES,
7
7
  selectorFromQualifiers,
8
+ selectorKey,
8
9
  } from "./artifact-selector.js";
10
+ import { readLibraryCandidates, type LibraryCandidate } from "./module-library.js";
9
11
  import { DiagnosticSeverity, type AnalysisDiagnostic } from "./types.js";
10
12
 
11
13
  const SOURCE = "telo-analyzer";
@@ -38,10 +40,64 @@ export function validateModuleArtifact(manifests: ResourceManifest[]): AnalysisD
38
40
  for (const manifest of manifests) {
39
41
  validateLayerIndex(manifest, out);
40
42
  validateControllerSelectors(manifest, out);
43
+ validateLibraryCandidates(manifest, out);
41
44
  }
42
45
  return out;
43
46
  }
44
47
 
48
+ /**
49
+ * The `exports.code:` block on a `Telo.Library` doc.
50
+ *
51
+ * Reported here rather than left to the loader for the same reason a controller
52
+ * selector is: an entry that cannot be read names no entry point, so a
53
+ * consumer's bundle falls back to *inlining* the library — the module scope
54
+ * duplication this whole mechanism exists to remove — and it does so silently, on
55
+ * someone else's machine.
56
+ */
57
+ function validateLibraryCandidates(manifest: ResourceManifest, out: AnalysisDiagnostic[]): void {
58
+ // `Telo.Library` only. An application is a root with no importer, so it has no
59
+ // `exports:` block at all — and its schema is `additionalProperties: false`,
60
+ // so AJV already rejects the key by name in this same pass. A second
61
+ // diagnostic on that node would be two squiggles saying one thing.
62
+ if (manifest.kind !== "Telo.Library") return;
63
+ const metadata = manifest.metadata as { name?: string; source?: string } | undefined;
64
+ const { candidates, problems } = readLibraryCandidates(manifest);
65
+ const resource = { kind: manifest.kind, name: metadata?.name };
66
+
67
+ for (const problem of problems) {
68
+ out.push({
69
+ severity: DiagnosticSeverity.Error,
70
+ code: "LIBRARY_CANDIDATE_INVALID",
71
+ source: SOURCE,
72
+ message: `Telo.Library/${metadata?.name ?? "(unnamed)"}: ${problem.origin}: ${problem.detail}`,
73
+ data: { resource, filePath: metadata?.source, path: "exports/code" },
74
+ });
75
+ }
76
+
77
+ // One specifier per selector: two candidates of one format claiming the same
78
+ // specifier leave the resolution ambiguous, and two specifiers for one format
79
+ // mean a consumer's import resolves by whichever candidate is read first.
80
+ const seen = new Map<string, LibraryCandidate>();
81
+ for (const candidate of candidates) {
82
+ const key = selectorKey(candidate.selector);
83
+ const first = seen.get(key);
84
+ if (first) {
85
+ out.push({
86
+ severity: DiagnosticSeverity.Error,
87
+ code: "LIBRARY_CANDIDATE_DUPLICATE",
88
+ source: SOURCE,
89
+ message:
90
+ `Telo.Library/${metadata?.name ?? "(unnamed)"}: two 'exports.code' entries declare the ` +
91
+ `selector ${key} ('${first.specifier}' and '${candidate.specifier}'). A module has one ` +
92
+ `entry point per format — which is what makes "one specifier, one module scope" true.`,
93
+ data: { resource, filePath: metadata?.source, path: "exports/code" },
94
+ });
95
+ continue;
96
+ }
97
+ seen.set(key, candidate);
98
+ }
99
+ }
100
+
45
101
  /** `local_path` names the source `path=` was built from, so a working copy runs
46
102
  * with no build step. It is inert in a published artifact — which ships no
47
103
  * `src/` — and contributes nothing to the selector, so it never affects which
@@ -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
+ }