@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
@@ -1,4 +1,5 @@
1
1
  import type { ResourceManifest } from "@telorun/sdk";
2
+ import { moduleMetadataSchema } from "./module-metadata-scope.js";
2
3
  import { residualEntrySchemaMap } from "./residual-schema.js";
3
4
  import { applyObservedStateNode } from "./validate-observed-state.js";
4
5
 
@@ -13,7 +14,13 @@ import { applyObservedStateNode } from "./validate-observed-state.js";
13
14
  * by declaring a typed `variables:`/`secrets:` entry with an `env:` binding
14
15
  * and referencing `variables.X` / `secrets.X`.
15
16
  */
16
- export const KERNEL_GLOBAL_NAMES = ["variables", "secrets", "resources", "ports"] as const;
17
+ export const KERNEL_GLOBAL_NAMES = [
18
+ "variables",
19
+ "secrets",
20
+ "resources",
21
+ "ports",
22
+ "module",
23
+ ] as const;
17
24
 
18
25
  const SYSTEM_KINDS = new Set([
19
26
  "Telo.Definition",
@@ -90,6 +97,7 @@ interface ModuleGlobals {
90
97
  variables?: Record<string, unknown>;
91
98
  secrets?: Record<string, unknown>;
92
99
  ports?: Record<string, unknown>;
100
+ module?: Record<string, unknown>;
93
101
  }
94
102
 
95
103
  function globalsSchema(
@@ -103,10 +111,24 @@ function globalsSchema(
103
111
  secrets: buildSchemaMapSchema(doc?.secrets as Record<string, any> | undefined),
104
112
  resources: resourcesSchema,
105
113
  ports: buildPortsSchema(doc?.ports as Record<string, any> | undefined),
114
+ module: buildModuleSchema(doc),
106
115
  },
107
116
  };
108
117
  }
109
118
 
119
+ /** The `module` namespace. Derived by `moduleMetadataSchema`, the same call the
120
+ * CEL environment types from, so the two cannot disagree about which fields
121
+ * exist or whether the namespace is closed. */
122
+ function buildModuleSchema(doc: ModuleGlobals | Record<string, any> | undefined): Record<string, any> {
123
+ return (
124
+ moduleMetadataSchema(
125
+ ((doc as ModuleGlobals | undefined)?.module ?? (doc as any)?.metadata) as
126
+ | Record<string, unknown>
127
+ | undefined,
128
+ ) ?? { type: "object", additionalProperties: true }
129
+ );
130
+ }
131
+
110
132
  /** Every non-system resource name in the set, plus the scope-declared ones. */
111
133
  function buildResourcesSchema(
112
134
  manifests: ResourceManifest[],
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Shared JSON-Schema fragments every module's `telo.yaml` may point at with
3
+ * `$ref: "telo://manifest#/$defs/<Name>"`.
4
+ *
5
+ * WHY THE ANALYZER OWNS THEM. These describe manifest structure, which is what
6
+ * this package exists to read — and the layering forces the choice anyway: the
7
+ * editor validates in a browser through `@telorun/analyzer`, and the analyzer
8
+ * must not depend on the kernel. A fragment in the kernel would exist only at
9
+ * runtime, so `telo check` and the editor could not see a step's shape at all.
10
+ * The kernel re-exports these from its own `manifest-schemas` surface, and both
11
+ * halves register the same root document with AJV, so a `$ref` resolves
12
+ * identically in the editor, in `telo check` and at dispatch.
13
+ *
14
+ * A fragment here is STRUCTURE, not a named user type. The two are different
15
+ * mechanisms and both exist:
16
+ *
17
+ * - `#/$defs/<Name>` is private to the declaring kind's own schema — Run's
18
+ * `WhileStep` is Run's business and nothing outside it can name one.
19
+ * - `telo://manifest#/$defs/<Name>` is this set: shapes the kernel defines, so
20
+ * several unrelated documents can agree on one. `builtins.ts` is not a module
21
+ * document and has no `$defs` any module could reach, which is why a shared
22
+ * shape cannot live in one of the modules that use it.
23
+ * - `telo:<module>/<Type>` names a `Telo.JsonSchema` resource a MODULE
24
+ * declared, resolved through the type registry and carrying its owner so two
25
+ * libraries may both declare a `Filter`.
26
+ *
27
+ * Adding a fragment means putting it under `$defs` in {@link ManifestRootSchema}
28
+ * and `$ref`-ing it from module schemas. Browser-safe: no Node built-ins.
29
+ */
30
+
31
+ export const MANIFEST_SCHEMA_URI = "telo://manifest";
32
+
33
+ /** `$ref` to a fragment in this set, as a module schema writes it. */
34
+ export function manifestFragmentRef(name: string): string {
35
+ return `${MANIFEST_SCHEMA_URI}#/$defs/${name}`;
36
+ }
37
+
38
+ /** Schema fragment for a resource-reference slot. The only form a manifest
39
+ * author writes is the `!ref <name>` (or `!ref <Alias>.<name>`) YAML tag,
40
+ * which parses to a `TaggedSentinel` (engine "ref") whose `source` is the
41
+ * bare resource name. In practice module schemas mark a ref slot with a bare
42
+ * `x-telo-ref` annotation (plus, where the slot only ever holds a reference,
43
+ * `type: object` to reject a stray scalar); the analyzer's reference walker
44
+ * reads `x-telo-ref` to look the name up against that constraint, independent
45
+ * of this fragment. A slot opts into this fragment only when it wants this
46
+ * exact two-branch shape enforced at the AJV layer too — it is not required,
47
+ * and slots that also accept an inline value (e.g. `inputType` / `outputType`)
48
+ * deliberately do not use it (an inline JSON Schema has no `kind`).
49
+ *
50
+ * Two `anyOf` branches because the value's shape depends on the phase at
51
+ * which it is validated:
52
+ *
53
+ * 1. The raw `!ref` sentinel — what survives to AJV when a cross-module
54
+ * reference can't be resolved in standalone single-file analysis (the
55
+ * imported module isn't loaded). `substituteCelFields` deliberately
56
+ * keeps the sentinel so this branch matches.
57
+ * 2. A resolved reference object — `{kind, name, alias?}` substituted in
58
+ * place of a sentinel (or an inline definition `{kind, ...config}`
59
+ * reached through a local `$ref` that escapes extraction). Both the
60
+ * kernel and the analyzer validate ref slots *after* sentinel
61
+ * resolution, so this is the shape AJV usually sees.
62
+ *
63
+ * The object-form `{kind, name}` reference a user could once type directly
64
+ * is gone: a plain object at a ref slot is only ever an inline definition
65
+ * or the resolver's own substitution, never an author-written reference.
66
+ * That removal is enforced by the analyzer (it rejects an author-written
67
+ * `{kind, name}` before normalization), not by this schema — branch 2
68
+ * cannot distinguish an author's `{kind, name}` from the resolver's. */
69
+ export const ResourceRefSchema = {
70
+ title: "Resource reference",
71
+ anyOf: [
72
+ {
73
+ type: "object",
74
+ required: ["__tagged", "engine", "source"],
75
+ properties: {
76
+ __tagged: { const: true },
77
+ engine: { const: "ref" },
78
+ source: { type: "string", minLength: 1 },
79
+ },
80
+ additionalProperties: false,
81
+ },
82
+ {
83
+ type: "object",
84
+ required: ["kind"],
85
+ properties: { kind: { type: "string" } },
86
+ additionalProperties: true,
87
+ },
88
+ ],
89
+ };
90
+
91
+ /**
92
+ * How a dispatch is re-attempted.
93
+ *
94
+ * ONE shape, because a retry policy is a cross-cutting primitive rather than
95
+ * each composer's own idea: it was written out six times across `run` and
96
+ * `http-client` and had already drifted — one copy declared no defaults, another
97
+ * carried a field the others lacked. Defaults live here, so the Node step leaf
98
+ * and a second-language one read the same numbers instead of each re-deriving
99
+ * them from the other's source.
100
+ *
101
+ * `delay` survives as the older duration-string spelling because published
102
+ * manifests carry it, and it cannot be migrated away: a migration entry writes a
103
+ * SCALAR, and `"250ms"` → `250` is a computation the vocabulary deliberately
104
+ * cannot express.
105
+ */
106
+ export const RetryPolicySchema = {
107
+ title: "Retry policy",
108
+ description:
109
+ "Re-attempts a failed dispatch with exponential backoff. A domain failure is " +
110
+ "retried; a cancellation and a contract violation are not — the latter is a " +
111
+ "property of the manifest and would fail identically every time.",
112
+ type: "object",
113
+ additionalProperties: false,
114
+ properties: {
115
+ attempts: {
116
+ title: "Attempts",
117
+ description: "Re-attempts after the first try. 0 disables retrying.",
118
+ type: "integer",
119
+ minimum: 0,
120
+ default: 0,
121
+ },
122
+ initialDelay: {
123
+ title: "Initial delay",
124
+ description: "Milliseconds to wait before the first re-attempt.",
125
+ type: "integer",
126
+ minimum: 0,
127
+ default: 250,
128
+ },
129
+ factor: {
130
+ title: "Factor",
131
+ description: "Multiplier applied to the delay after each re-attempt.",
132
+ type: "number",
133
+ minimum: 1,
134
+ default: 2,
135
+ },
136
+ maxDelay: {
137
+ title: "Max delay",
138
+ description: "Ceiling on the delay between re-attempts, in milliseconds.",
139
+ type: "integer",
140
+ minimum: 0,
141
+ default: 32000,
142
+ },
143
+ jitter: {
144
+ title: "Jitter",
145
+ description:
146
+ "`full` picks each delay uniformly from [0, delay], which is what stops " +
147
+ "work that failed together from re-attempting together.",
148
+ type: "string",
149
+ enum: ["none", "full"],
150
+ default: "full",
151
+ },
152
+ delay: {
153
+ title: "Delay",
154
+ description:
155
+ "DEPRECATED duration string (`250ms`, `1s`) — read as `initialDelay` when " +
156
+ "that is absent. The pattern is what makes a typo a `telo check` failure " +
157
+ "instead of a silently different backoff.",
158
+ type: "string",
159
+ pattern: "^[0-9]+(\\.[0-9]+)?\\s*(ms|s|m|h)$",
160
+ },
161
+ },
162
+ };
163
+
164
+ /**
165
+ * The bare-count spelling of a re-attempt — `Http.Request.retries`, deprecated
166
+ * but carried by every manifest published before `retry:` existed.
167
+ *
168
+ * Its own fragment rather than a special case in the reader: what a consumer
169
+ * needs to know is WHERE the budget is, and pointing at this shape says "the
170
+ * value itself" as precisely as pointing at a policy says "its `attempts`". It
171
+ * cannot be migrated to the policy form — a migration entry writes a scalar, and
172
+ * wrapping one in an object is not something the vocabulary can express.
173
+ */
174
+ export const RetryAttemptsSchema = {
175
+ title: "Retry attempts",
176
+ description: "Re-attempts after the first try. 0 disables retrying.",
177
+ type: "integer",
178
+ minimum: 0,
179
+ default: 0,
180
+ };
181
+
182
+ /**
183
+ * A DISPATCH SITE: name a target, pass it arguments, optionally guard it,
184
+ * optionally re-attempt it.
185
+ *
186
+ * The runtime has always had exactly one of these — `InvokeStep` in the SDK, run
187
+ * by `executeInvokeStep`, which every dispatch passes through. What did not exist
188
+ * was the schema half: the shape was hand-restated by each composer (four times
189
+ * in `run`, once in `builtins.ts`) and they drifted, which is why `retry:` worked
190
+ * in a sequence step and was a schema error one line away in `targets:` — not a
191
+ * decision anyone made about boot, just a copy that never grew the field.
192
+ *
193
+ * Owning it here is also what retired `x-telo-retry` for a step: the analyzer
194
+ * reads `step.retry.attempts` because that is what a step IS, rather than
195
+ * discovering a retry-bearing field through a marker the kind had to remember to
196
+ * write.
197
+ *
198
+ * `name` is optional: a boot target only needs one to publish
199
+ * `steps.<name>.result`, and a composer that requires one says so in its own
200
+ * schema. Closed, so a misspelled key is rejected wherever a dispatch is written.
201
+ */
202
+ export const InvokeStepSchema = {
203
+ title: "Invoke step",
204
+ description:
205
+ "Transfers control to a resource: what to call, what to pass it, whether to " +
206
+ "call it, and how to re-attempt it.",
207
+ type: "object",
208
+ required: ["invoke"],
209
+ additionalProperties: false,
210
+ properties: {
211
+ name: {
212
+ title: "Name",
213
+ description: "Publishes this dispatch's result as `steps.<name>.result`.",
214
+ type: "string",
215
+ },
216
+ invoke: {
217
+ title: "Invoke",
218
+ description: "Resource to invoke.",
219
+ "x-telo-topology-role": "invoke",
220
+ // A reference is always an object (a `!ref` sentinel or its resolved
221
+ // `{kind, name}`); requiring an object rejects a bare-string ref — which
222
+ // `validateReferenceForms` cannot catch at this nested slot — at
223
+ // `telo check` instead of as an obscure runtime failure.
224
+ type: "object",
225
+ "x-telo-ref": {
226
+ kind: "Telo.Executable",
227
+ use: "call",
228
+ inputs: "/inputs",
229
+ },
230
+ },
231
+ inputs: {
232
+ title: "Inputs",
233
+ description: "Values passed to the invoked resource.",
234
+ "x-telo-topology-role": "inputs",
235
+ type: "object",
236
+ additionalProperties: true,
237
+ },
238
+ when: {
239
+ title: "When",
240
+ description: "CEL guard — the dispatch is skipped when it evaluates false.",
241
+ type: "string",
242
+ },
243
+ retry: {
244
+ title: "Retry",
245
+ $ref: `${MANIFEST_SCHEMA_URI}#/$defs/RetryPolicy`,
246
+ },
247
+ },
248
+ };
249
+
250
+ /** Recursively freeze, so the fragment set cannot be edited through any of the
251
+ * references handed out. `fragmentFor` clones precisely because downstream
252
+ * passes rewrite schemas in place — `resolveSchemaRefKinds` rewrites the very
253
+ * `x-telo-ref` node `InvokeStep` carries — and a consumer that embedded a
254
+ * fragment WITHOUT cloning would corrupt every later expansion process-wide, in
255
+ * a host that outlives one load (the editor, the LSP). Freezing turns that from
256
+ * a rule someone has to remember into a throw at the write. */
257
+ function deepFreeze<T>(value: T): T {
258
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
259
+ Object.freeze(value);
260
+ for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
261
+ }
262
+ return value;
263
+ }
264
+
265
+ /** Root schema registered with AJV under {@link MANIFEST_SCHEMA_URI}. Carries
266
+ * `$defs` only — it isn't validated against directly. */
267
+ export const ManifestRootSchema = {
268
+ $id: MANIFEST_SCHEMA_URI,
269
+ $defs: {
270
+ ResourceRef: ResourceRefSchema,
271
+ RetryPolicy: RetryPolicySchema,
272
+ RetryAttempts: RetryAttemptsSchema,
273
+ InvokeStep: InvokeStepSchema,
274
+ },
275
+ };
276
+
277
+ deepFreeze(ManifestRootSchema);
278
+
279
+ /** A private, expanded copy of a fragment, for a consumer that must EMBED one
280
+ * rather than `$ref` it — `builtins.ts` is not a manifest and never passes
281
+ * through the loader, so its dispatch site has to arrive already resolved and
282
+ * already stamped. Cloned for the reason {@link deepFreeze} explains. */
283
+ export function manifestFragment(name: string): Record<string, unknown> {
284
+ const fragment = (ManifestRootSchema.$defs as Record<string, unknown>)[name];
285
+ if (!fragment || typeof fragment !== "object") {
286
+ throw new Error(`Unknown manifest fragment '${name}'`);
287
+ }
288
+ const copy = structuredClone(fragment) as Record<string, unknown>;
289
+ expandManifestFragments(copy);
290
+ copy[X_TELO_FRAGMENT] = name;
291
+ return copy;
292
+ }
293
+
294
+ const FRAGMENT_PREFIX = `${MANIFEST_SCHEMA_URI}#/$defs/`;
295
+
296
+ /**
297
+ * Replace every `telo://manifest#/$defs/<Name>` reference with the fragment
298
+ * itself, in place, throughout a parsed manifest.
299
+ *
300
+ * EXPANDED rather than left as a reference, which is the opposite of what
301
+ * `resolveSchemaTypeRefs` does for a named user type — and for the opposite
302
+ * reasons. A user type must stay a reference because it can recurse and because
303
+ * the compiled-validator cache is keyed on schema identity. These fragments are a
304
+ * closed, non-recursive set the analyzer itself owns, and expanding them is what
305
+ * keeps a composer that points at a shared shape legible to walks that never
306
+ * resolved anything: the CEL-placeholder substitution, the eval-path collector,
307
+ * the editor's field walk. Teaching each of those to follow a reference is the
308
+ * same fix applied N times, and the failure mode when one is missed is silent —
309
+ * a role-driven lookup finds nothing and the check it feeds simply stops
310
+ * reporting.
311
+ *
312
+ * Runs in the shared loader, so both kernels' Node halves and every consumer of a
313
+ * loaded manifest see the same expanded shape.
314
+ */
315
+ export function expandManifestFragments(node: unknown, seen = new Set<object>()): void {
316
+ if (!node || typeof node !== "object") return;
317
+ if (seen.has(node as object)) return;
318
+ seen.add(node as object);
319
+
320
+ if (Array.isArray(node)) {
321
+ for (let i = 0; i < node.length; i++) {
322
+ const fragment = fragmentFor(node[i]);
323
+ if (fragment) node[i] = fragment;
324
+ else expandManifestFragments(node[i], seen);
325
+ }
326
+ return;
327
+ }
328
+
329
+ const obj = node as Record<string, unknown>;
330
+ for (const [key, value] of Object.entries(obj)) {
331
+ const fragment = fragmentFor(value);
332
+ if (fragment) obj[key] = fragment;
333
+ else expandManifestFragments(value, seen);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * The fragment a node references, expanded and merged with whatever the node
339
+ * declared beside the `$ref`, or undefined when it references none.
340
+ *
341
+ * SIBLINGS ARE MERGED, which draft-07 would ignore — `$ref` is exclusive there,
342
+ * so `{ $ref, properties: {...} }` silently drops the properties and `allOf` is
343
+ * the only standard alternative. `allOf` cannot preserve
344
+ * `additionalProperties: false`, because a branch only ever sees its own
345
+ * `properties`; a kind extending the shared retry policy with one HTTP-specific
346
+ * field would have had to give up a closed schema to do it. Merging at expansion
347
+ * gives the 2019-09 reading — `$ref` composes rather than replaces — on the
348
+ * draft the validators actually run.
349
+ *
350
+ * The node's own keys WIN, and `properties` merge key-wise, so an extension adds
351
+ * fields without restating the shared ones.
352
+ *
353
+ * The result is STAMPED with the fragment it came from. That stamp is what
354
+ * replaced `x-telo-retry`: a consumer asking "does this field declare a
355
+ * re-attempt, and where is the budget" reads which shape the author pointed at,
356
+ * rather than a marker the author had to remember to write beside it. Derived,
357
+ * never authored — the same standing as `metadata.exportedKinds` — and stripped
358
+ * before AJV like every other `x-telo-*` key, so it cannot affect validation.
359
+ *
360
+ * Deep-copied because downstream passes (`resolveSchemaRefKinds`, migrations)
361
+ * rewrite schemas in place, and a shared object would let one manifest's rewrite
362
+ * reach every other manifest that pointed at the same shape.
363
+ */
364
+ function fragmentFor(value: unknown): Record<string, unknown> | undefined {
365
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
366
+ const node = value as Record<string, unknown>;
367
+ const ref = node.$ref;
368
+ if (typeof ref !== "string" || !ref.startsWith(FRAGMENT_PREFIX)) return undefined;
369
+ const name = ref.slice(FRAGMENT_PREFIX.length);
370
+ const fragment = (ManifestRootSchema.$defs as Record<string, unknown>)[name];
371
+ if (!fragment || typeof fragment !== "object") return undefined;
372
+
373
+ const expanded = structuredClone(fragment) as Record<string, unknown>;
374
+ // A fragment may reference another (InvokeStep holds a RetryPolicy); the copy
375
+ // is expanded too, so one pass leaves no reference behind.
376
+ expandManifestFragments(expanded);
377
+
378
+ for (const [key, own] of Object.entries(node)) {
379
+ if (key === "$ref") continue;
380
+ if (key === "properties" && isPlainObject(own) && isPlainObject(expanded.properties)) {
381
+ expanded.properties = { ...expanded.properties, ...own };
382
+ continue;
383
+ }
384
+ if (key === "required" && Array.isArray(own) && Array.isArray(expanded.required)) {
385
+ expanded.required = [...new Set([...expanded.required, ...own])];
386
+ continue;
387
+ }
388
+ expanded[key] = own;
389
+ }
390
+ expanded[X_TELO_FRAGMENT] = name;
391
+ return expanded;
392
+ }
393
+
394
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
395
+ return !!value && typeof value === "object" && !Array.isArray(value);
396
+ }
397
+
398
+ /** Stamped by {@link expandManifestFragments} with the name of the shared
399
+ * fragment a slot pointed at. Derived, never author-written. */
400
+ export const X_TELO_FRAGMENT = "x-telo-fragment";
401
+
402
+ /** The shared fragment a schema node was expanded from, or undefined. The one
403
+ * accessor every consumer reads the stamp through. */
404
+ export function manifestFragmentOf(schema: unknown): string | undefined {
405
+ if (!schema || typeof schema !== "object") return undefined;
406
+ const name = (schema as Record<string, unknown>)[X_TELO_FRAGMENT];
407
+ return typeof name === "string" ? name : undefined;
408
+ }
@@ -7,6 +7,7 @@ import {
7
7
  import { PackageURL } from "packageurl-js";
8
8
  import { parseAllDocuments } from "yaml";
9
9
  import { selectorFromQualifiers, selectorKey, type ArtifactSelector } from "./artifact-selector.js";
10
+ import { readLibraryCandidates } from "./module-library.js";
10
11
 
11
12
  /**
12
13
  * One module-relative file a manifest names, and the artifact layer it belongs
@@ -51,6 +52,14 @@ interface ClaimBase {
51
52
  * controller claim with no selector that nothing would reject.
52
53
  */
53
54
  export type ModuleFileClaim =
55
+ | (ClaimBase & {
56
+ readonly role: "library";
57
+ readonly selector: ArtifactSelector;
58
+ /** The bare specifier a consumer's bundle imports this entry point by. */
59
+ readonly specifier: string;
60
+ /** The source `path` was built from, as on a controller claim. */
61
+ readonly localPath?: string;
62
+ })
54
63
  | (ClaimBase & {
55
64
  readonly role: "controller";
56
65
  readonly selector: ArtifactSelector;
@@ -58,6 +67,12 @@ export type ModuleFileClaim =
58
67
  * `.gitignore`-style globs over the selected files, matched by the
59
68
  * caller, which is the side that knows what was selected. */
60
69
  readonly siblings: readonly string[];
70
+ /** The source `path` was built from (`local_path=`), when the candidate
71
+ * names one. The release path builds the entry point rather than reading
72
+ * a prebuilt file, so it needs the source — and re-deriving it by parsing
73
+ * `origin` would put PURL knowledge back into the consumer, which is
74
+ * exactly what this module exists to hold. */
75
+ readonly localPath?: string;
61
76
  })
62
77
  | (ClaimBase & { readonly role: "assets" });
63
78
 
@@ -97,6 +112,7 @@ function controllerClaims(json: unknown): ModuleFileClaim[] {
97
112
  if (parsed.type !== BUNDLED_TYPE || parsed.namespace !== BUNDLED_NAMESPACE) continue;
98
113
  const entry = parsed.qualifiers?.path;
99
114
  if (typeof entry !== "string" || entry === "") continue;
115
+ const localPath = parsed.qualifiers?.local_path;
100
116
  claims.push({
101
117
  role: "controller",
102
118
  path: normalizeRelative(entry),
@@ -105,12 +121,31 @@ function controllerClaims(json: unknown): ModuleFileClaim[] {
105
121
  .split(",")
106
122
  .map((p) => p.trim())
107
123
  .filter((p) => p !== ""),
124
+ ...(typeof localPath === "string" && localPath !== ""
125
+ ? { localPath: normalizeRelative(localPath) }
126
+ : {}),
108
127
  origin: candidate,
109
128
  });
110
129
  }
111
130
  return claims;
112
131
  }
113
132
 
133
+ /** The library entry points one document's `library:` block names. Unlike a
134
+ * controller entry — reached only when this module's own kinds instantiate —
135
+ * this one is what a *sibling* resolves a bare specifier to, which is why it
136
+ * gets its own layer rather than riding in the controller layer: a consumer
137
+ * must reach it without loading this module's controllers. */
138
+ function libraryClaims(json: unknown): ModuleFileClaim[] {
139
+ return readLibraryCandidates(json).candidates.map((candidate) => ({
140
+ role: "library",
141
+ path: candidate.path,
142
+ selector: candidate.selector,
143
+ specifier: candidate.specifier,
144
+ ...(candidate.localPath ? { localPath: candidate.localPath } : {}),
145
+ origin: candidate.origin,
146
+ }));
147
+ }
148
+
114
149
  /** Claims contributed by tagged values, asked of the engine that owns each tag.
115
150
  * The walk reaches every tagged scalar in the document, so an engine that
116
151
  * embeds files is discovered wherever its tag was written.
@@ -136,7 +171,8 @@ function taggedClaims(json: unknown, registry: TemplatingEngineRegistry): Module
136
171
  * their layers — dropping one would leave a platform's layer short a file it
137
172
  * declared it needs. */
138
173
  function claimKey(claim: ModuleFileClaim): string {
139
- const selector = claim.role === "controller" ? selectorKey(claim.selector) : "";
174
+ const selector =
175
+ claim.role === "controller" || claim.role === "library" ? selectorKey(claim.selector) : "";
140
176
  return `${claim.role}\0${selector}\0${claim.path}`;
141
177
  }
142
178
 
@@ -157,7 +193,11 @@ export function collectModuleFileClaims(
157
193
  const claims: ModuleFileClaim[] = [];
158
194
  for (const doc of parseAllDocuments(manifestText, { customTags: defaultCustomTags() })) {
159
195
  const json = doc.toJSON() as unknown;
160
- for (const claim of [...controllerClaims(json), ...taggedClaims(json, registry)]) {
196
+ for (const claim of [
197
+ ...libraryClaims(json),
198
+ ...controllerClaims(json),
199
+ ...taggedClaims(json, registry),
200
+ ]) {
161
201
  const key = claimKey(claim);
162
202
  if (seen.has(key)) continue;
163
203
  seen.add(key);